From cfc58fbcb4b700f7ec7d5f555705ff3d06dfa077 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:27:57 +0000 Subject: [PATCH 01/50] Delete AppMap.ps1 --- AppMap.ps1 | 589 ----------------------------------------------------- 1 file changed, 589 deletions(-) delete mode 100644 AppMap.ps1 diff --git a/AppMap.ps1 b/AppMap.ps1 deleted file mode 100644 index 88802ee..0000000 --- a/AppMap.ps1 +++ /dev/null @@ -1,589 +0,0 @@ -$ProgressPreference = 'SilentlyContinue' - -$script:TecharyApps = @{ -#region Adobe Reader -"adobereader" = @{ - DisplayName = "Adobe Reader" - RepoPath = "a/Adobe/Acrobat/Reader/64-bit" - YamlFile = "Adobe.Acrobat.Reader.64-bit.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*AcroRdrDCx64\S*\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*AcroRdrDCx64\S*\.exe)' - InstallerType = "exe" - ExeInstallArgs = "-sfx_nu /sAll /rs /msi" - IsWinget = $true - WingetID = "Adobe.Acrobat.Reader.64-bit" - } -#endregion - -#region Adobe Creative Cloud -"adobecc" = @{ - DisplayName = "Adobe Creative Cloud" - RepoPath = "a/Adobe/CreativeCloud" - YamlFile = "Adobe.CreativeCloud.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(https://prod-rel-ffc-ccm\.oobesaas\.adobe\.com/adobe-ffc-external/core/v1/wam/download\?sapCode=KCCC&wamFeature=nuj-live)' - PatternARM64 = 'InstallerUrl:\s*(https://prod-rel-ffc-ccm\.oobesaas\.adobe\.com/adobe-ffc-external/core/v1/wam/download\?sapCode=KCCC&wamFeature=nuj-live)' - InstallerType = "exe" - ExeInstallArgs = "--mode=stub" - IsWinget = $true - WingetID = "Adobe.CreativeCloud" - } -#endregion - -#region Microsoft PowerToys -"powertoys" = @{ - DisplayName = "PowerToys" - RepoPath = "m/Microsoft/PowerToys" - YamlFile = "Microsoft.PowerToys.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/PowerToysSetup-\S*-x64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/PowerToysSetup-\S*-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/quiet /norestart" - IsWinget = $true - WingetID = "Microsoft.PowerToys" -} -#endregion - -#region Mozilla Firefox -"firefox" = @{ - DisplayName = "Firefox" - RepoPath = "m/Mozilla/Firefox/en-GB" - YamlFile = "Mozilla.Firefox.en-GB.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/win64/en-GB/Firefox%20Setup%20\S+\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/win64-aarch64/en-GB/Firefox%20Setup%20\S+\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/S /PreventRebootRequired=true" - IsWinget = $true - WingetID = "Adobe.CreativeCloud" -} -#endregion - -#region Slack -"slack" = @{ - DisplayName = "Slack" - RepoPath = "s/SlackTechnologies/Slack" - YamlFile = "SlackTechnologies.Slack.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/x64/\S*/slack-standalone-\S+\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/x64/\S*/slack-standalone-\S+\.msi)' - InstallerType = "msi" - ExeInstallArgs = "/S /PreventRebootRequired=true" - IsWinget = $true - WingetID = "SlackTechnologies.Slack" - } -#endregion - -#region Winget Auto Update -"wingetautoupdate" = @{ - DisplayName = "Winget Auto Update" - RepoPath = "r/Romanitho/Winget-AutoUpdate" - YamlFile = "Romanitho.Winget-AutoUpdate.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/WAU\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/WAU\.msi)' - InstallerType = "msi" - ExeInstallArgs = "/S /PreventRebootRequired=true" - IsWinget = $true - WingetID = "Romanitho.Winget-AutoUpdate" - } -#endregion - -#region RingCentral -"ringcentral" = @{ - DisplayName = "RingCentral" - RepoPath = "r/RingCentral/RingCentral" - YamlFile = "RingCentral.RingCentral.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/RingCentral-\d+\.\d+\.\d+-x64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/RingCentral-\d+\.\d+\.\d+-arm64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "RingCentral.RingCentral" - } -#endregion - -#region Microsoft Visual Studio Code -"vscode" = @{ - DisplayName = "Microsoft Visual Studio Code" - RepoPath = "m/Microsoft/VisualStudioCode" - YamlFile = "Microsoft.VisualStudioCode.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*VSCodeSetup\S*x64\S*\.(exe|msi))' - PatternARM64 = 'InstallerUrl:\s*(\S*VSCodeSetup\S*arm64\S*\.(exe|msi))' - InstallerType = "exe" - ExeInstallArgs = "/VERYSILENT /MERGETASKS=!runcode" - IsWinget = $true - WingetID = "Microsoft.VisualStudioCode" -} -#endregion - -#region Jabra Direct -"jabradirect" = @{ - DisplayName = "Jabra Direct" - RepoPath = "j/Jabra/Direct" - YamlFile = "Jabra.Direct.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/JabraDirectSetup\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/JabraDirectSetup\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/install /quiet /norestart" - IsWinget = $true - WingetID = "Jabra.Direct" -} -#endregion - -#region Bitwarden -"bitwarden" = @{ - DisplayName = "Bitwarden" - RepoPath = "b/Bitwarden/Bitwarden" - YamlFile = "Bitwarden.Bitwarden.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Bitwarden-Installer-\S+\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Bitwarden-Installer-\S+\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/allusers /S" - IsWinget = $true - WingetID = "Bitwarden.Bitwarden" -} -#endregion - -#region Git -"git" = @{ - DisplayName = "Git" - RepoPath = "g/Git/Git" - YamlFile = "Git.Git.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Git-\d+\.\d+\.\d+(-windows-\d+)?-64-bit\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Git-\d+\.\d+\.\d+(-windows-\d+)?-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART" - IsWinget = $true - WingetID = "Git.Git" -} -#endregion - -#region 7zip -"7zip" = @{ - DisplayName = "7zip" - RepoPath = "7/7zip/7zip" - YamlFile = "7zip.7zip.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/7z\d+-x64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/7z\d+-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/S" - IsWinget = $true - WingetID = "7zip.7zip" -} -#endregion - -#region Dell Command -"dellcommand" = @{ - DisplayName = "Dell Command" - RepoPath = "d/Dell/CommandUpdate" - YamlFile = "Dell.CommandUpdate.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Dell-Command-Update-Application\S*WIN64\S*\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Dell-Command-Update-Application\S*WIN64\S*\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/passthrough /S /V/quiet /V/norestart" - IsWinget = $true - WingetID = "Dell.CommandUpdate" -} -#endregion - -#region Microsoft Power Automate -"powerautomate" = @{ - DisplayName = "Microsoft Power Automate Desktop" - RepoPath = "m/Microsoft/PowerAutomateDesktop" - YamlFile = "Microsoft.PowerAutomateDesktop.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Setup\.Microsoft\.PowerAutomate\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Setup\.Microsoft\.PowerAutomate\.exe)' - InstallerType = "exe" - ExeInstallArgs = "-Silent -ACCEPTEULA" - IsWinget = $true - WingetID = "Microsoft.PowerAutomateDesktop" -} -#endregion - -#region Microsoft PowerBi -"powerbi" = @{ - DisplayName = "Microsoft Power BI" - RepoPath = "m/Microsoft/PowerBI" - YamlFile = "Microsoft.PowerBI.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/PBIDesktopSetup-\d{4}-\d{2}_x64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/PBIDesktopSetup-\d{4}-\d{2}_x64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "-silent ACCEPT_EULA=1" - IsWinget = $true - WingetID = "Microsoft.PowerBI" -} -#endregion - -#region Python 3.14 -"python314" = @{ - DisplayName = "Pyhton 3.14" - RepoPath = "p/Python/Python/3/14" - YamlFile = "Python.Python.3.14.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/python-\d+\.\d+\.\d+-amd64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/python-\d+\.\d+\.\d+-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/passive /quiet InstallAllUsers=1" - IsWinget = $true - WingetID = "Python.Python.3.14" - } -#endregion - -#region Docker Desktop -"dockerdesktop" = @{ - DisplayName = "Docker Desktop" - RepoPath = "d/Docker/DockerDesktop" - YamlFile = "Docker.DockerDesktop.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/amd64/\d+/Docker%20Desktop%20Installer\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/arm64/\d+/Docker%20Desktop%20Installer\.exe)' - InstallerType = "exe" - ExeInstallArgs = "install --quiet" - IsWinget = $true - WingetID = "Docker.DockerDesktop" - } -#endregion - -#region Java Runtime Environment -"java" = @{ - DisplayName = "Java Runtime Environment" - RepoPath = "o/Oracle/JavaRuntimeEnvironment" - YamlFile = "Oracle.JavaRuntimeEnvironment.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(https://javadl\.oracle\.com/webapps/download/AutoDL\?BundleId=\d+_[a-fA-F0-9]+)' - PatternARM64 = 'InstallerUrl:\s*(https://javadl\.oracle\.com/webapps/download/AutoDL\?BundleId=\d+_[a-fA-F0-9]+)' - InstallerType = "exe" - ExeInstallArgs = "/s REBOOT=0" - IsWinget = $true - WingetID = "Oracle.JavaRuntimeEnvironment" -} -#endregion - -#region ReMarkable -"remarkable" = @{ - DisplayName = "ReMarkable Companion App" - RepoPath = "r/reMarkable/reMarkableCompanionApp" - YamlFile = "reMarkable.reMarkableCompanionApp.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/reMarkable-\S*-win64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/reMarkable-\S*-win64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "install --confirm-command --default-answer --accept-licenses" - IsWinget = $true - WingetID = "reMarkable.reMarkableCompanionApp" -} -#endregion - -#region Logi Options -"logioptions" = @{ - DisplayName = "Logi Options Plus" - RepoPath = "l/Logitech/OptionsPlus" - YamlFile = "Logitech.OptionsPlus.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/logioptionsplus_installer\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/logioptionsplus_installer\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/quiet /analytics no" - IsWinget = $true - WingetID = "Logitech.OptionsPlus" -} -#endregion - -#region Sublime Text -"sublimetext" = @{ - DisplayName = "Sublime Text" - RepoPath = "s/SublimeHQ/SublimeText/4" - YamlFile = "SublimeHQ.SublimeText.4.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/sublime_text_build_\d+_x64_setup\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/sublime_text_build_\d+_x64_setup\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/VERYSILENT /NORESTART" - IsWinget = $true - WingetID = "SublimeHQ.SublimeText.4" -} -#endregion - -#region Microsoft DotNet SDK 10 -"microsoftdotnetsdk" = @{ - DisplayName = "Microsoft DotNet SDK 10" - RepoPath = "m/Microsoft/DotNet/SDK/10" - YamlFile = "Microsoft.DotNet.SDK.10.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/dotnet-sdk-\d+\.\d+\.\d+-win-x64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/dotnet-sdk-\d+\.\d+\.\d+-win-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/quiet" - IsWinget = $true - WingetID = "Microsoft.DotNet.SDK.10" -} -#endregion - -#region Microsoft DotNet Runtime 10 -"microsoftdotnetruntime" = @{ - DisplayName = "Microsoft DotNet Runtime 10" - RepoPath = "m/Microsoft/DotNet/Runtime/10" - YamlFile = "Microsoft.DotNet.Runtime.10.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/dotnet-runtime-\d+\.\d+\.\d+-win-x64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/dotnet-runtime-\d+\.\d+\.\d+-win-arm64\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/quiet" - IsWinget = $true - WingetID = "Microsoft.DotNet.Runtime.10" -} -#endregion - -#region PuTTy -"putty" = @{ - DisplayName = "PuTTy" - RepoPath = "p/PuTTY/PuTTY" - YamlFile = "PuTTY.PuTTY.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/putty-64bit-\d+\.\d+-installer\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/putty-arm64-\d+\.\d+-installer\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "PuTTY.PuTTY" -} -#endregion - -#region Go Programming Language -"golang" = @{ - DisplayName = "Go Programming Language" - RepoPath = "g/GoLang/Go" - YamlFile = "GoLang.Go.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/go\d+\.\d+\.\d+\.windows-amd64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/go\d+\.\d+\.\d+\.windows-arm64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "GoLang.Go" -} -#endregion - -#region Cisco Webex -"webex" = @{ - DisplayName = "Cisco Webex" - RepoPath = "c/Cisco/Webex" - YamlFile = "Cisco.Webex.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Webex\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Webex\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "Cisco.Webex" -} -#endregion - -#region Microsoft Edge -"edge" = @{ - DisplayName = "Microsoft Edge" - RepoPath = "m/Microsoft/Edge" - YamlFile = "Microsoft.Edge.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/MicrosoftEdgeEnterpriseX64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/MicrosoftEdgeEnterpriseARM64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "Microsoft.Edge" -} -#endregion - -#region PDF24 -"pdf24" = @{ - DisplayName = "PDF24 Creator" - RepoPath = "g/geeksoftwareGmbH/PDF24Creator" - YamlFile = "geeksoftwareGmbH.PDF24Creator.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/pdf24-creator-\d+\.\d+\.\d+-x64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/pdf24-creator-\d+\.\d+\.\d+-arm64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "geeksoftwareGmbH.PDF24Creator" -} -#endregion - -#region 8x8 Work -"8x8work" = @{ - DisplayName = "8x8 Work" - RepoPath = "8/8x8/Work" - YamlFile = "8x8.Work.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S+)' - PatternARM64 = 'InstallerUrl:\s*(\S+)' - InstallerType = "msi" - IsWinget = $true - WingetID = "8x8.Work" -} -#endregion - -#region Powershell 7 -"powershell7" = @{ - DisplayName = "Powershell 7" - RepoPath = "m/Microsoft/PowerShell" - YamlFile = "Microsoft.PowerShell.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/PowerShell-\d+\.\d+\.\d+-win-x64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/PowerShell-\d+\.\d+\.\d+-win-arm64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "Microsoft.PowerShell" -} -#endregion - -#region Wireshark -"wireshark" = @{ - DisplayName = "Wireshark" - RepoPath = "w/WiresharkFoundation/Wireshark" - YamlFile = "WiresharkFoundation.Wireshark.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Wireshark-\S*-x64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Wireshark-\S*-x64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "WiresharkFoundation.Wireshark" -} -#endregion - -#region Zoom -"zoom" = @{ - DisplayName = "Zoom" - RepoPath = "z/Zoom/Zoom" - YamlFile = "Zoom.Zoom.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/ZoomInstallerFull\.msi\?archType=x64)' - PatternARM64 = 'InstallerUrl:\s*(\S*/ZoomInstallerFull\.msi\?archType=winarm64)' - InstallerType = "msi" - IsWinget = $true - WingetID = "Zoom.Zoom" -} -#endregion - -#region GitHub Desktop -"githubdesktop" = @{ - DisplayName = "GitHub Desktop" - RepoPath = "g/GitHub/GitHubDesktop" - YamlFile = "GitHub.GitHubDesktop.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S+.*64.*\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S+.*arm64.*\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "GitHub.GitHubDesktop" -} -#endregion - -#region VLC Media Player -"vlc" = @{ - DisplayName = "VLC Media Player" - RepoPath = "v/VideoLAN/VLC" - YamlFile = "VideoLAN.VLC.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/vlc-\d+\.\d+\.\d+-win64\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/vlc-\d+\.\d+\.\d+-win64\.exe)' - InstallerType = "exe" - IsWinget = $true - ExeInstallArgs = "/S" - WingetID = "VideoLAN.VLC" -} -#endregion - -#region NodeJS -"nodejs" = @{ - DisplayName = "NodeJS" - RepoPath = "o/OpenJS/NodeJS" - YamlFile = "OpenJS.NodeJS.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/node-v\S*-x64\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S*/node-v\S*-arm64\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "OpenJS.NodeJS" -} -#endregion - -#region Google Chrome -"chrome" = @{ - DisplayName = "Google Chrome" - RepoPath = "g/Google/Chrome" - YamlFile = "Google.Chrome.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S+.*64.*\.msi)' - PatternARM64 = 'InstallerUrl:\s*(\S+.*arm64.*\.msi)' - InstallerType = "msi" - IsWinget = $true - WingetID = "Google.Chrome" -} -#endregion - -#region Display Link -"displaylink" = @{ - DisplayName = "Display Link" - RepoPath = "d/DisplayLink/GraphicsDriver" - YamlFile = "DisplayLink.GraphicsDriver.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S+)' - PatternARM64 = 'InstallerUrl:\s*(\S+)' - InstallerType = "zip" - IsWinget = $true - WingetID = "DisplayLink.GraphicsDriver" -} -#endregion - - - - - - - -########################## -########################## -#region NOT WINGET APPS ## -########################## -########################## -########################## -########################## -########################## -#region Windows App -"windowsapp" = @{ - DisplayName = "Windows App" - IsWinget = $false - DownloadUrl = "https://go.microsoft.com/fwlink/?linkid=2262633" - InstallerType = "msix" -} -#endregion - -#region MyDPD -"mydpd" = @{ - DisplayName = "MyDPD Customer" - IsWinget = $false - DownloadUrl = "https://apis.my.dpd.co.uk/apps/download/public" - InstallerType = "exe" - ExeInstallArgs = "--Silent" -} -#endregion - -#region Royal Mail Print Assist -"royalmail" = @{ - DisplayName = "Royal Mail Print Assist" - IsWinget = $false - DownloadUrl = "http://app.printnode.com/download/client/royalmail/windows" - InstallerType = "exe" - ExeInstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES" -} -#endregion - -#region Crosschex -"crosschex" = @{ - DisplayName = "Crosschex" - IsWinget = $false - DownloadUrl = "https://www.anviz.com/file/download/5539/CrossChex_Standard_4.3.16.exe" - InstallerType = "exe" - ExeInstallArgs = "/exenoui ALLUSERS=1 /qn" -} -#endregion - -#region Coreldraw -"coreldraw" = @{ - DisplayName = "Coreldraw" - IsWinget = $false - DownloadUrl = "https://www.corel.com/akdlm/6763/downloads/free/trials/GraphicsSuite/2019/R5tgO2Wx1/getdl/CorelDRAWGraphicsSuite2019Installer_AM.exe" - InstallerType = "exe" - ExeInstallArgs = "/qn" -} -#endregion - -#region N-Able RMM Agent -# How to Install -#Install-TecharyApp -AppName "nable" -Parameters @{ -# CustomerID = "123456" This can be found in N-Central under Administration > Customers, there you will see the Access Code column -# Token = "abcdefg" Token is got under Actions > Add/Import Devices > Get Registration Token -# CustomerName = '\"Company Name From N-Central\"' #This has to be formatted like this with the name of their customer in nable -# ServerAddress = "Refer to Confluence guide for the server address" -# } - -"nable" = @{ - DisplayName = "N-Able RMM Agent" - IsWinget = $false - InstallerType = "exe" - ExeInstallArgs = "/qn /v" -} -#endregion - - -} From 806f558f9ac91a1caae5c693651513dc0cbd7a38 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:28:07 +0000 Subject: [PATCH 02/50] Delete Intune-Packager.ps1 --- Intune-Packager.ps1 | 176 -------------------------------------------- 1 file changed, 176 deletions(-) delete mode 100644 Intune-Packager.ps1 diff --git a/Intune-Packager.ps1 b/Intune-Packager.ps1 deleted file mode 100644 index 7c4c5bb..0000000 --- a/Intune-Packager.ps1 +++ /dev/null @@ -1,176 +0,0 @@ -$ProgressPreference = 'SilentlyContinue' - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# === Configurable Paths === -$global:SourceRoot = "C:\IntuneApps\Source" -$global:OutputRoot = "C:\IntuneApps\Output" -$global:WinAppToolDir = "C:\IntuneApps\IntuneWinAppTool" -$global:WinAppToolExe = Join-Path $WinAppToolDir "Microsoft-Win32-Content-Prep-Tool-1.8.7\IntuneWinAppUtil.exe" -$global:RemoteAppMapUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/refs/heads/main/AppMap.ps1" - -# === Ensure Required Folders Exist === -$null = New-Item -ItemType Directory -Path $SourceRoot -Force -$null = New-Item -ItemType Directory -Path $OutputRoot -Force -$null = New-Item -ItemType Directory -Path $WinAppToolDir -Force - -# === Download IntuneWinAppUtil === -function Get-IntuneWinAppUtil { - if (-Not (Test-Path $global:WinAppToolExe)) { - Write-Host "Downloading IntuneWinAppUtil..." - $zipUrl = "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/archive/refs/tags/v1.8.7.zip" - $zipPath = Join-Path $WinAppToolDir "tool.zip" - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - Expand-Archive -Path $zipPath -DestinationPath $WinAppToolDir -Force - Remove-Item $zipPath -Force - } - - return $global:WinAppToolExe -} - -# === Load Remote AppMap === -function Load-TecharyApps { - $tempAppMap = Join-Path $env:TEMP "AppMap.ps1" - Invoke-WebRequest -Uri $global:RemoteAppMapUrl -OutFile $tempAppMap -UseBasicParsing - - $script:TecharyApps = @{} - . $tempAppMap - Remove-Item $tempAppMap -Force -} - -# === Build GUI === -$form = New-Object System.Windows.Forms.Form -$form.Text = "TecharyGet Intune App Packager" -$form.Size = New-Object System.Drawing.Size(460, 400) -$form.StartPosition = "CenterScreen" - -$label = New-Object System.Windows.Forms.Label -$label.Text = "Select an app to package:" -$label.Location = New-Object System.Drawing.Point(20, 20) -$label.AutoSize = $true -$form.Controls.Add($label) - -$comboBox = New-Object System.Windows.Forms.ComboBox -$comboBox.Location = New-Object System.Drawing.Point(20, 50) -$comboBox.Size = New-Object System.Drawing.Size(400, 30) -$comboBox.DropDownStyle = "DropDownList" -$form.Controls.Add($comboBox) - -# === Dynamic Fields for "nable" app === -$extraFields = @{} -$fieldDefinitions = @( - @{ Name = "CustomerID"; Label = "Customer ID:"; Y = 100 }, - @{ Name = "Token"; Label = "Token:"; Y = 130 }, - @{ Name = "CustomerName"; Label = "Customer Name:"; Y = 160 }, - @{ Name = "ServerAddress"; Label = "Server Address:"; Y = 190 } -) - -foreach ($field in $fieldDefinitions) { - $label = New-Object System.Windows.Forms.Label - $label.Text = $field.Label - $label.Location = New-Object System.Drawing.Point(20, $field.Y) - $label.Size = New-Object System.Drawing.Size(120, 20) - $label.Visible = $false - $form.Controls.Add($label) - - $textbox = New-Object System.Windows.Forms.TextBox - $textbox.Location = New-Object System.Drawing.Point(150, $field.Y) - $textbox.Size = New-Object System.Drawing.Size(270, 20) - $textbox.Visible = $false - $form.Controls.Add($textbox) - - $extraFields[$field.Name] = @{ Label = $label; TextBox = $textbox } -} - -# === Button === -$button = New-Object System.Windows.Forms.Button -$button.Text = "Create IntuneWin Package" -$button.Location = New-Object System.Drawing.Point(20, 230) -$button.Size = New-Object System.Drawing.Size(400, 40) -$form.Controls.Add($button) - -# === Status === -$statusLabel = New-Object System.Windows.Forms.Label -$statusLabel.Location = New-Object System.Drawing.Point(20, 290) -$statusLabel.Size = New-Object System.Drawing.Size(400, 60) -$form.Controls.Add($statusLabel) - -# === Load and Populate App List === -Load-TecharyApps -$comboBox.Items.AddRange(@($script:TecharyApps.Keys | Sort-Object)) - -# === ComboBox Change - show/hide "nable" fields === -$comboBox.Add_SelectedIndexChanged({ - $selected = $comboBox.SelectedItem - $isNable = $selected -eq "nable" - foreach ($field in $extraFields.Values) { - $field.Label.Visible = $isNable - $field.TextBox.Visible = $isNable - } -}) - -# === Button Logic === -$button.Add_Click({ - $appName = $comboBox.SelectedItem - if (-not $appName) { - $statusLabel.Text = "Please select an app." - return - } - - try { - $appFolder = Join-Path $SourceRoot $appName - $null = New-Item -ItemType Directory -Path $appFolder -Force - - # === Prepare install script - $installScript = Join-Path $appFolder "install-$appName.ps1" - if ($appName -eq "nable") { - $params = @{} - foreach ($key in $extraFields.Keys) { - $value = $extraFields[$key].TextBox.Text - if (-not $value) { - $statusLabel.Text = "$key is required for 'nable'." - return - } - $params[$key] = $value - } - - $installContent = @" -Install-TecharyApp -AppName `"nable`" -Parameters @{ - CustomerID = `'$($params.CustomerID)`' - Token = `'$($params.Token)`' - CustomerName = `'$($params.CustomerName)`' - ServerAddress = `'$($params.ServerAddress)`' -} -"@ - } else { - $installContent = "Install-TecharyApp -AppName `"$appName`"" - } - - # === Create uninstall script - $uninstallScript = Join-Path $appFolder "uninstall-$appName.ps1" - $uninstallContent = "Uninstall-TecharyApp -AppName `"$appName`"" - - # === Write Scripts - Set-Content -Path $installScript -Value $installContent -Encoding UTF8 - Set-Content -Path $uninstallScript -Value $uninstallContent -Encoding UTF8 - - # === Package using IntuneWinAppUtil - $intuneWinAppUtil = Get-IntuneWinAppUtil - $outputPath = Join-Path $OutputRoot $appName - $null = New-Item -ItemType Directory -Path $outputPath -Force - - $statusLabel.Text = "Packaging $appName..." - & $intuneWinAppUtil -c $appFolder -s ("install-$appName.ps1") -o $outputPath | Out-Null - - $statusLabel.Text = "$appName packaged successfully. Output: $outputPath" - } - catch { - $statusLabel.Text = "Error: $($_.Exception.Message)" - } -}) - -# === Show GUI === -$form.Topmost = $true -$form.Add_Shown({ $form.Activate() }) -[void]$form.ShowDialog() From f8d2b4e04095e75a5ea28c61e2d6f67debfc91b2 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:30:12 +0000 Subject: [PATCH 03/50] Delete TecharyGet.psd1 --- TecharyGet.psd1 | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 TecharyGet.psd1 diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 deleted file mode 100644 index 414d048..0000000 --- a/TecharyGet.psd1 +++ /dev/null @@ -1,49 +0,0 @@ -@{ - # Script module file associated with this manifest - RootModule = 'TecharyGet.psm1' - - # Version of this module - ModuleVersion = '1.4' - - # ID used to uniquely identify this module - GUID = '8d777e7e-fd28-4e34-bf9d-0c325bb81a76' - - # Author of this module - Author = 'Adam Sweetapple' - - # Company or vendor of this module - CompanyName = 'Techary' - - # Copyright - Copyright = '(c) 2025 Techary. All rights reserved.' - - # Description of the module - Description = 'A PowerShell module for managing app installations and uninstalls using Winget Repo, MSI, EXE, ZIP, and MSIX sources. Supports custom logic and Intune deployment.' - - # Minimum version of PowerShell required - PowerShellVersion = '5.1' - - # Functions to export - FunctionsToExport = "Install-TecharyApp","Uninstall-TecharyApp","Help-TecharyApp","Get-TecharyAppList","Update-TecharyGetModule" - - # Cmdlets to export - CmdletsToExport = @() - - # Variables to export - VariablesToExport = @() - - # Aliases to export - AliasesToExport = @() - - # Private data to pass to PowerShell - PrivateData = @{ - - PSData = @{ - Tags = @('winget', 'installer', 'automation', 'techary', 'uninstall', 'intune') - ProjectUri = 'https://github.com/Techary/TecharyGet' - } - } -} - - - From 987470a51de711f8f4858b7bb8f303025534296d Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:30:19 +0000 Subject: [PATCH 04/50] Delete TecharyGet.psm1 --- TecharyGet.psm1 | 463 ------------------------------------------------ 1 file changed, 463 deletions(-) delete mode 100644 TecharyGet.psm1 diff --git a/TecharyGet.psm1 b/TecharyGet.psm1 deleted file mode 100644 index 749d731..0000000 --- a/TecharyGet.psm1 +++ /dev/null @@ -1,463 +0,0 @@ -#region Globals -$script:folderPath = "C:\Logs\TecharyGetLogs" -$ProgressPreference = 'SilentlyContinue' -#endregion - -#region Logging -function Invoke-LogMessage { - param ([string]$Message) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logFile = Join-Path $script:folderPath "TecharyGet.log" - Add-Content -Path $logFile -Value "[$timestamp] $Message" -} -#endregion - -#region Install App -function Install-TecharyApp { - param ( - [string]$AppName, - [hashtable]$Parameters - ) - - #If AppMap.ps1 exists, remove it to ensure we get the latest version - $AppMapexists = Test-Path "$PSScriptRoot\AppMap.ps1" - if ($AppMapexists) { - Remove-Item "$PSScriptRoot\AppMap.ps1" -Force - } - - # Download the latest AppMap.ps1 - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Techary/TecharyGet/refs/heads/main/AppMap.ps1" -OutFile "$PSScriptRoot\AppMap.ps1" -UseBasicParsing - - if (-not $script:TecharyApps) { - . "$PSScriptRoot\AppMap.ps1" - } - - $AppKey = $AppName.ToLower() - if (-not $script:TecharyApps.ContainsKey($AppKey)) { - throw "[TecharyGet] Application '$AppName' not found in AppMap." - } - - $app = $script:TecharyApps[$AppKey] - - if (-not $app.MsiInstallArgs) { $app.MsiInstallArgs = "ALLUSERS=1 /quiet" } - if (-not $app.ExeInstallArgs) { $app.ExeInstallArgs = "/S" } - - if (-not (Test-Path $script:folderPath)) { - New-Item -Path $script:folderPath -ItemType Directory -Force | Out-Null - } - - # Handle special app logic or fallback -if ($AppKey -eq "nable") { - $required = @("CustomerID", "Token", "CustomerName", "ServerAddress") - foreach ($key in $required) { - if (-not $Parameters.ContainsKey($key)) { - throw "[Nable] Missing required parameter: $key" - } - } - - $customerID = $Parameters.CustomerID - $token = $Parameters.Token - $customerName = $Parameters.CustomerName - $serverAddress = $Parameters.ServerAddress - - # Ensure folder exists - if (-not (Test-Path $script:folderPath)) { - New-Item -Path $script:folderPath -ItemType Directory -Force | Out-Null - } - - $fileName = "Nable_RMMInstaller.exe" - $installerPath = Join-Path $script:folderPath $fileName - $downloadUrl = "https://$serverAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" - - Invoke-LogMessage "[Nable] Downloading installer from: $downloadUrl" - Invoke-WebRequest -Uri $downloadUrl -OutFile $installerPath -UseBasicParsing - - $msiArgs = "/qn CUSTOMERID=$customerID CUSTOMERNAME=$customerName CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$token SERVERPROTOCOL=HTTPS SERVERADDRESS=$serverAddress SERVERPORT=443" - $arguments = "/S /v`"$msiArgs`"" - - Invoke-LogMessage "[Nable] Installing with arguments: $arguments" - Start-Process -FilePath $installerPath -ArgumentList $arguments -Wait -NoNewWindow - - Remove-Item $installerPath -Force - Invoke-LogMessage "[Nable] Installed and cleaned up" - return -} - - - # Custom static download - if (-not $app.IsWinget -and $app.DownloadUrl) { - $arch = (Get-ComputerInfo).CSArchitecture - $suffix = if ($arch -like "*ARM*") { "arm64" } else { "x64" } - - $ext = [System.IO.Path]::GetExtension($app.DownloadUrl) - if (-not $ext) { $ext = ".exe" } - - $fileName = "${AppKey}_Installer_${suffix}${ext}" - $installerPath = Join-Path $script:folderPath $fileName - - Invoke-LogMessage "[$AppName] Downloading installer from: $($app.DownloadUrl)" - Invoke-WebRequest -Uri $app.DownloadUrl -OutFile $installerPath -UseBasicParsing - - switch ($app.InstallerType) { - "exe" { - Start-Process -FilePath $installerPath -ArgumentList $app.ExeInstallArgs -Wait -NoNewWindow - } - "msi" { - Start-Process "msiexec.exe" -ArgumentList "/i `"$installerPath`" $($app.MsiInstallArgs)" -Wait -NoNewWindow - } - "msix" { - Add-AppxProvisionedPackage -Online -PackagePath $installerPath -SkipLicense - } - default { - throw "[$AppName] Unsupported installer type for custom app." - } - } - - Invoke-LogMessage "[$AppName] Installed successfully." - Remove-Item $installerPath -Force - return - } - - # Winget-based install - Install-TecharyWingetApp ` - -AppName $AppName ` - -RepoPath $app.RepoPath ` - -YamlFileName $app.YamlFile ` - -PatternX64 $app.PatternX64 ` - -PatternARM64 $app.PatternARM64 ` - -InstallerType $app.InstallerType ` - -ExeInstallArgs $app.ExeInstallArgs ` - -MsiInstallArgs $app.MsiInstallArgs -} -#endregion - -#region Uninstall App -function Uninstall-TecharyApp { - param ( - [string]$AppName - ) - - #If AppMap.ps1 exists, remove it to ensure we get the latest version - $AppMapexists = Test-Path "$PSScriptRoot\AppMap.ps1" - if ($AppMapexists) { - Remove-Item "$PSScriptRoot\AppMap.ps1" -Force - } - - # Download the latest AppMap.ps1 - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Techary/TecharyGet/refs/heads/main/AppMap.ps1" -OutFile "$PSScriptRoot\AppMap.ps1" -UseBasicParsing - - if (-not $script:TecharyApps) { - . "$PSScriptRoot\AppMap.ps1" - } - - $AppKey = $AppName.ToLower() - if (-not $script:TecharyApps.ContainsKey($AppKey)) { - throw "[TecharyGet] Application '$AppName' not found in AppMap." - } - - $app = $script:TecharyApps[$AppKey] - $displayName = $app.DisplayName - - # Determine if using winget - if ($app.IsWinget -and $app.WingetID) { - $arch = (Get-ComputerInfo).CSArchitecture - $wingetBasePath = if ($arch -like "*ARM*") { - "C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_*_arm64__8wekyb3d8bbwe" - } else { - "C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe" - } - - $resolveWinget = Resolve-Path -Path $wingetBasePath -ErrorAction SilentlyContinue | Sort-Object -Descending | Select-Object -First 1 - if ($resolveWinget) { - $wingetExe = Join-Path $resolveWinget.Path "winget.exe" - } - - if (-not (Test-Path $wingetExe)) { - throw "[Uninstall] Winget executable not found." - } - - Invoke-LogMessage "[Uninstall] Uninstalling '$displayName' via winget ID: $($app.WingetID)" - & $wingetExe uninstall --id $($app.WingetID) --silent --scope machine --exact | Out-Null - Invoke-LogMessage "[Uninstall] Winget uninstall complete for $displayName" - return - } - - # Otherwise fallback to registry-based uninstall logic - $uninstallKeys = @( - "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $uninstallEntry = $null - foreach ($key in $uninstallKeys) { - $uninstallEntry = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue | Where-Object { - $_.DisplayName -like "*$displayName*" - } | Select-Object -First 1 - - if ($uninstallEntry) { break } - } - - if (-not $uninstallEntry) { - throw "[Uninstall] Could not find uninstall entry for $displayName" - } - - $uninstallCommand = $uninstallEntry.UninstallString - if (-not $uninstallCommand) { - throw "[Uninstall] No uninstall command found for $displayName" - } - -# MSI uninstall via ProductCode -if ($uninstallCommand -match "msiexec\.exe.*" -or $uninstallEntry.PSChildName -match "^\{.*\}$") { - $productCode = $uninstallEntry.PSChildName - if ($productCode -match "^\{.*\}$") { - $msiArgs = "/x $productCode /qn REBOOT=ReallySuppress" - Invoke-LogMessage "[Uninstall] Executing MSI uninstall: msiexec.exe $msiArgs" - Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -NoNewWindow - Invoke-LogMessage "[Uninstall] MSI uninstall completed for $displayName" - return - } -} - - - # EXE uninstallers (including quoted paths and arguments) - if ($uninstallCommand -match '^(\"?[^"]+\.exe\"?)\s*(.*)$') { - $exePathRaw = $matches[1] - $args = $matches[2] - - $exePath = $exePathRaw.Trim('"').Trim() - - if (-not (Test-Path $exePath)) { - try { - $exePath = (Get-Item $exePath -ErrorAction Stop).FullName - } catch { - throw "Uninstall EXE not found: `"$exePath`"" - } - } - - Invoke-LogMessage "[Uninstall] Executing EXE uninstall: $exePath $args" - Start-Process -FilePath $exePath -ArgumentList $args -Wait -NoNewWindow - Invoke-LogMessage "[Uninstall] Uninstall completed for $displayName" - return - } - - throw "[Uninstall] Uninstall command not recognized for $displayName" -} -#endregion - -#region Install-TecharyWingetApp -function Install-TecharyWingetApp { - param ( - [string]$AppName, - [string]$RepoPath, - [string]$YamlFileName, - [string]$PatternX64, - [string]$PatternARM64, - [ValidateSet("msi", "exe", "zip", "msix")] [string]$InstallerType, - [string]$ExeInstallArgs = "/S", - [string]$MsiInstallArgs = "ALLUSERS=1 /quiet" - ) - - $arch = (Get-ComputerInfo).CSDescription - $isARM = $arch -like "*ARM*" - $suffix = if ($isARM) { "arm64" } else { "x64" } - - $versionUrl = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$RepoPath" - $headers = @{ "User-Agent" = "PowerShell" } - $response = Invoke-RestMethod -Uri $versionUrl -Headers $headers - - $versions = $response | - Where-Object { $_.type -eq "dir" } | - ForEach-Object { $_.name } | - Where-Object { $_ -match '^\d+(\.\d+)*$' } - - if (-not $versions) { - throw "[$AppName] No valid version folders found in repo path: $RepoPath" -} - - $latestVersion = $versions | - Sort-Object { [version]$_ } -Descending | - Select-Object -First 1 - Invoke-LogMessage "[$AppName] Latest version detected: $latestVersion" - - $yamlUrl = "https://raw.githubusercontent.com/microsoft/winget-pkgs/refs/heads/master/manifests/$RepoPath/$latestVersion/$YamlFileName" - Invoke-LogMessage "[$AppName] YAML URL: $yamlUrl" - - try { - $yamlContent = Invoke-WebRequest -Uri $yamlUrl -UseBasicParsing - $yamlText = $yamlContent.Content - } catch { - throw "[$AppName] Failed to download YAML: $_" - } - - $installerUrl = $null - if ($isARM -and $yamlText -match $PatternARM64) { - $installerUrl = $matches[1] - } elseif ($yamlText -match $PatternX64) { - $installerUrl = $matches[1] - } else { - throw "[$AppName] Installer URL not found in YAML." - } - - # Clean the filename from the URL (removing query parameters like ?archType=x64) - $cleanInstallerUrl = $installerUrl -split '\?' | Select-Object -First 1 - $ext = [System.IO.Path]::GetExtension($cleanInstallerUrl) - - if (-not $ext) { $ext = ".exe" } - - $fileName = "${AppName}_Installer_${suffix}${ext}" - $installerPath = Join-Path $script:folderPath $fileName -` - # Now download the file using the full URL - Invoke-LogMessage "[$AppName] Downloading installer from: $installerUrl" - Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing - - switch ($InstallerType) { - "msi" { - Start-Process "msiexec.exe" -ArgumentList "/i `"$installerPath`" $MsiInstallArgs" -Wait -NoNewWindow - } - "exe" { - Start-Process -FilePath $installerPath -ArgumentList $ExeInstallArgs -Wait -NoNewWindow - } - "msix" { - Add-AppxProvisionedPackage -Online -PackagePath $installerPath -SkipLicense - } - "zip" { - $extractPath = Join-Path $script:folderPath "$AppName-$suffix" - Expand-Archive -Path $installerPath -DestinationPath $extractPath -Force - $allMsis = Get-ChildItem -Path $extractPath -Recurse -Filter *.msi - - $preferredMsi = $allMsis | Where-Object { - $osArch = (Get-ComputerInfo).OSArchitecture - if ($osArch -like "*ARM*") { - $_.Name -match "arm64" - } else { - $_.Name -match "x64" - } - } | Select-Object -First 1 - - if (-not $preferredMsi) { - $preferredMsi = $allMsis | Select-Object -First 1 - } - - Start-Process "msiexec.exe" -ArgumentList "/i `"$($preferredMsi.FullName)`" $MsiInstallArgs" -Wait -NoNewWindow - Remove-Item -Path $extractPath -Recurse -Force - } - } - - Remove-Item $installerPath -Force - Invoke-LogMessage "[$AppName] Installed successfully." -} -#endregion - -#region Help-TecharyApp -function Help-TecharyApp { - Write-Host "" - Write-Host "TecharyApp PowerShell Module Help" -ForegroundColor Cyan - Write-Host "==================================" -ForegroundColor Cyan - Write-Host "" - Write-Host "Available Commands:" -ForegroundColor Yellow - Write-Host " Install-TecharyApp -AppName [-Parameters ]" -ForegroundColor Green - Write-Host " Uninstall-TecharyApp -AppName " -ForegroundColor Green - Write-Host " Get-TecharyAppList" -ForegroundColor Green - Write-Host " Help-TecharyApp" -ForegroundColor Green - Write-Host "" - Write-Host "Examples:" -ForegroundColor Yellow - Write-Host ' Install-TecharyApp -AppName "7zip"' - Write-Host ' Uninstall-TecharyApp -AppName "chrome"' - Write-Host ' Install-TecharyApp -AppName "nable" -Parameters @{ CustomerID="123"; Token="abc"; CustomerName="Org"; ServerAddress="control.example.com" }' - Write-Host "" - Write-Host "Tip:" -ForegroundColor Yellow - Write-Host " Use Get-TecharyAppList to see all available AppNames." - Write-Host "" -} -#endregion - -#region Get-TecharyAppList -function Get-TecharyAppList { - - #If AppMap.ps1 exists, remove it to ensure we get the latest version - $AppMapexists = Test-Path "$PSScriptRoot\AppMap.ps1" - if ($AppMapexists) { - Remove-Item "$PSScriptRoot\AppMap.ps1" -Force - } - - # Download the latest AppMap.ps1 - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Techary/TecharyGet/refs/heads/main/AppMap.ps1" -OutFile "$PSScriptRoot\AppMap.ps1" -UseBasicParsing - - if (-not $script:TecharyApps) { - . "$PSScriptRoot\AppMap.ps1" - } - - $script:TecharyApps.Keys | Sort-Object | ForEach-Object { - $app = $script:TecharyApps[$_] - [PSCustomObject]@{ - AppKey = $_ - DisplayName = $app.DisplayName - InstallerType = $app.InstallerType - IsWinget = $app.IsWinget - WingetID = $app.WingetID - } - } -} -#endregion - -#region Update-TecharyApp -function Update-TecharyGetModule { - param ( - [string]$RepoOwner = "Techary", - [string]$RepoName = "TecharyGet", - [string]$Branch = "main", - [string]$ModuleName = "TecharyGet", - [string]$ModulePath = "$PSScriptRoot" - ) - - $localPSD = Join-Path $ModulePath "$ModuleName.psd1" - if (-not (Test-Path $localPSD)) { - Write-Host "Local .psd1 file not found at $localPSD" - return - } - - # Get local version - $localModule = Import-PowerShellDataFile -Path $localPSD - $localVersion = [version]$localModule.ModuleVersion - Write-Host "Local version: $localVersion" - - # Get remote psd1 raw content from GitHub - $remotePSDUrl = "https://raw.githubusercontent.com/$RepoOwner/$RepoName/refs/heads/$Branch/$ModuleName.psd1" - try { - $remoteText = Invoke-WebRequest -Uri $remotePSDUrl -UseBasicParsing - $tempFile = Join-Path $env:TEMP "$ModuleName.remote.psd1" - $remoteText.Content | Set-Content -Path $tempFile -Encoding UTF8 - $remoteModule = Import-PowerShellDataFile -Path $tempFile - $remoteVersion = [version]$remoteModule.ModuleVersion - Remove-Item $tempFile -Force - } catch { - Write-Host "Failed to fetch remote version: $_" - return - } - - Write-Host "Remote version: $remoteVersion" - - if ($remoteVersion -gt $localVersion) { - Write-Host "Updating module from GitHub..." - - $filesToDownload = @("$ModuleName.psm1", "$ModuleName.psd1") - foreach ($file in $filesToDownload) { - $url = "https://raw.githubusercontent.com/$RepoOwner/$RepoName/refs/heads/$Branch/$file" - $dest = Join-Path $ModulePath $file - try { - Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing - Write-Host "Updated: $file" - } catch { - Write-Warning "Failed to update ${file}: $_" - } - } - - Write-Host "Update complete." - } else { - Write-Host "Module is up to date." - } -} - -#endregion From 4004e2342e14d3e08a44bc3121d4a74825b432d8 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:32:57 +0000 Subject: [PATCH 05/50] Add files via upload --- AppPackager.psd1 | 133 ++++++++++++++++++++++++++++++ AppPackager.psm1 | 4 + Private/CustomApps.json | 23 ++++++ Private/Get-CustomApp.ps1 | 67 +++++++++++++++ Public/Get-GitHubInstaller.ps1 | 114 +++++++++++++++++++++++++ Public/Install-AppPackage.ps1 | 103 +++++++++++++++++++++++ Public/Install-NableAgent.ps1 | 80 ++++++++++++++++++ Public/Install-SmartApp.ps1 | 68 +++++++++++++++ Public/Invoke-PackagerCleanup.ps1 | 12 +++ Public/New-IntunePackage.ps1 | 70 ++++++++++++++++ Public/New-IntunePackageUI.ps1 | 93 +++++++++++++++++++++ Public/Uninstall-SmartApp.ps1 | 111 +++++++++++++++++++++++++ Public/Write-PackagerLog.ps1 | 37 +++++++++ 13 files changed, 915 insertions(+) create mode 100644 AppPackager.psd1 create mode 100644 AppPackager.psm1 create mode 100644 Private/CustomApps.json create mode 100644 Private/Get-CustomApp.ps1 create mode 100644 Public/Get-GitHubInstaller.ps1 create mode 100644 Public/Install-AppPackage.ps1 create mode 100644 Public/Install-NableAgent.ps1 create mode 100644 Public/Install-SmartApp.ps1 create mode 100644 Public/Invoke-PackagerCleanup.ps1 create mode 100644 Public/New-IntunePackage.ps1 create mode 100644 Public/New-IntunePackageUI.ps1 create mode 100644 Public/Uninstall-SmartApp.ps1 create mode 100644 Public/Write-PackagerLog.ps1 diff --git a/AppPackager.psd1 b/AppPackager.psd1 new file mode 100644 index 0000000..7463d6e --- /dev/null +++ b/AppPackager.psd1 @@ -0,0 +1,133 @@ +# +# Module manifest for module 'AppPackager' +# +# Generated by: asweetapple +# +# Generated on: 04/02/2026 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'AppPackager.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '9f3ce1a8-1494-4b4a-b885-0c1112fc234e' + +# Author of this module +Author = 'asweetapple' + +# Company or vendor of this module +CompanyName = 'Unknown' + +# Copyright statement for this module +Copyright = '(c) asweetapple. All rights reserved.' + +# Description of the functionality provided by this module +# Description = '' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = 'Get-GitHubInstaller', 'Install-AppPackage', 'Write-PackagerLog', + 'Invoke-PackagerCleanup', 'Install-GitHubApp' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/AppPackager.psm1 b/AppPackager.psm1 new file mode 100644 index 0000000..4285024 --- /dev/null +++ b/AppPackager.psm1 @@ -0,0 +1,4 @@ +$Root = Split-Path $MyInvocation.MyCommand.Path +Get-ChildItem -Path "$Root\Private\*.ps1" | ForEach-Object { . $_.FullName } +Get-ChildItem -Path "$Root\Public\*.ps1" | ForEach-Object { . $_.FullName } +Export-ModuleMember -Function (Get-ChildItem -Path "$Root\Public\*.ps1").BaseName diff --git a/Private/CustomApps.json b/Private/CustomApps.json new file mode 100644 index 0000000..29e4be8 --- /dev/null +++ b/Private/CustomApps.json @@ -0,0 +1,23 @@ +[ + { + "Id": "MyDPD", + "Url": "https://apis.my.dpd.co.uk/apps/download/public", + "SilentArgs": "--Silent", + "InstallerType": "exe", + "DisplayName": "MyDPD" + }, + { + "Id": "RoyalMail", + "Url": "http://app.printnode.com/download/client/royalmail/windows", + "SilentArgs": "/VERYSILENT /SUPPRESSMSGBOXES", + "InstallerType": "exe", + "DisplayName": "Royal Mail" + }, + { + "Id": "Crosschex", + "Url": "https://www.anviz.com/file/download/5539/CrossChex_Standard_4.3.16.exe", + "SilentArgs": "/exenoui ALLUSERS=1 /qn", + "InstallerType": "exe", + "DisplayName": "Crosschex" + } +] \ No newline at end of file diff --git a/Private/Get-CustomApp.ps1 b/Private/Get-CustomApp.ps1 new file mode 100644 index 0000000..4a6cae4 --- /dev/null +++ b/Private/Get-CustomApp.ps1 @@ -0,0 +1,67 @@ +function Get-CustomApp { + param ( + [string]$Id + ) + + # --- 1. CLOUD SOURCE --- + # We use the "Raw" GitHub URL so we get just the JSON text. + # Structure: https://raw.githubusercontent.com//// + $CloudUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json" + + # --- 2. LOCAL CACHE --- + # We cache the file locally so the script works even if GitHub is briefly down + # or if the machine is offline (using the last known good copy). + $CacheDir = "$env:PROGRAMDATA\TecharyGet" + $CachePath = "$CacheDir\CustomApps_Cache.json" + + # --- 3. SYNC LOGIC --- + try { + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } + + # Logic: Only download if the cache doesn't exist OR it's older than 60 minutes. + # This prevents spamming GitHub every time you run a command. + $NeedUpdate = $true + if (Test-Path $CachePath) { + $LastWrite = (Get-Item $CachePath).LastWriteTime + if ((Get-Date) -lt $LastWrite.AddMinutes(60)) { $NeedUpdate = $false } + } + + if ($NeedUpdate) { + Write-PackagerLog -Message "Syncing Custom Catalog from GitHub..." -Severity Info + Invoke-WebRequest -Uri $CloudUrl -OutFile $CachePath -UseBasicParsing -ErrorAction Stop + } + } + catch { + Write-PackagerLog -Message "Could not sync from GitHub (Offline?). Using local cache." -Severity Warning + } + + # --- 4. READ DATA --- + $JsonContent = $null + + # Prefer the fresh Cache + if (Test-Path $CachePath) { + $JsonContent = Get-Content -Path $CachePath -Raw + } + # Fallback to the file shipped with the module (if cache is empty/broken) + else { + $LocalModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Private\CustomApps.json" + if (Test-Path $LocalModulePath) { + $JsonContent = Get-Content -Path $LocalModulePath -Raw + } + } + + # --- 5. PARSE & RETURN --- + if ($JsonContent) { + try { + $Data = $JsonContent | ConvertFrom-Json + $Match = $Data | Where-Object { $_.Id -eq $Id } + return $Match + } + catch { + Write-PackagerLog -Message "Error parsing CustomApps.json: $_" -Severity Error + return $null + } + } + + return $null +} \ No newline at end of file diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 new file mode 100644 index 0000000..d35a27f --- /dev/null +++ b/Public/Get-GitHubInstaller.ps1 @@ -0,0 +1,114 @@ +function Get-GitHubInstaller { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Id, + [string]$DownloadPath = "$env:TEMP\AppPackager" + ) + + Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" + + try { + # 1. Detect Architecture + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + Write-PackagerLog -Message "System Architecture: $SysArch" + + # 2. Construct API Path + $IdPath = $Id.Replace(".", "/") + $FirstChar = $Id.Substring(0,1).ToLower() + $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" + + # 3. Get Version + $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -ErrorAction Stop + $LatestVersionObj = $VersionsResponse | + Where-Object { $_.type -eq "dir" } | + Select-Object *, @{N='ParsedVersion'; E={ try { [Version]$_.name } catch { $null } }} | + Where-Object { $_.ParsedVersion -ne $null } | + Sort-Object ParsedVersion -Descending | + Select-Object -First 1 + + if (-not $LatestVersionObj) { throw "Could not determine a valid numeric version." } + $LatestVersion = $LatestVersionObj.Name + Write-PackagerLog -Message "Latest Version Found: $LatestVersion" + + # 4. Get Manifest + $VersionPath = "$BaseApi/$LatestVersion" + $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get + $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 + if (-not $InstallerFile) { throw "No installer YAML found." } + + Write-PackagerLog -Message "Parsing Manifest: $($InstallerFile.name)" + $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url + + # --- PARSING LOGIC (Fixed Regex) --- + $Blocks = $YamlContent -split 'Architecture:\s*' + + $SelectedUrl = $null + $SelectedArgs = $null + $SelectedType = "exe" + + # Search Blocks for Match + foreach ($Block in $Blocks) { + if ([string]::IsNullOrWhiteSpace($Block)) { continue } + $BlockArch = $Block.Split("`r`n")[0].Trim() + + if ($BlockArch -eq $SysArch) { + # We found our block! + if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } + if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } + + # FIX: Greedy Regex to capture arguments with quotes (like /v"/qn") + if ($Block -match 'Silent:\s*(.+)') { + # Remove leading/trailing quotes from the YAML value itself, but keep internal quotes + $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') + } + elseif ($Block -match 'SilentWithProgress:\s*(.+)') { + $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') + } + break + } + } + + # Fallback: Scan Whole File if missing + if (-not $SelectedUrl) { + if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } + } + + if (-not $SelectedArgs) { + # FIX: Greedy Global Regex + if ($YamlContent -match 'Silent:\s*(.+)') { + $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') + Write-PackagerLog -Message "Found Global Arguments." + } + elseif ($YamlContent -match 'SilentWithProgress:\s*(.+)') { + $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') + } + } + + if (-not $SelectedUrl) { throw "Could not find an installer URL." } + + # --- DOWNLOAD --- + $UriObj = [System.Uri]$SelectedUrl + $RealExtension = [System.IO.Path]::GetExtension($UriObj.LocalPath).ToLower() + if (-not $RealExtension) { $RealExtension = ".$SelectedType" } + $FileName = "$Id-$LatestVersion-$SysArch$RealExtension" + + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + $FullPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Downloading to $FullPath..." + $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + Invoke-WebRequest -Uri $SelectedUrl -OutFile $FullPath -UseBasicParsing -UserAgent $UserAgent + + return [PSCustomObject]@{ + Name = $Id; InstallerPath = $FullPath; FileName = $FileName; SilentArgs = $SelectedArgs; InstallerType = $SelectedType + } + } + catch { + Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 new file mode 100644 index 0000000..8fbf4c8 --- /dev/null +++ b/Public/Install-AppPackage.ps1 @@ -0,0 +1,103 @@ +function Install-AppPackage { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$FilePath, + [Parameter(Mandatory=$false)][object]$Arguments = @(), + [string]$Name = "Unknown Application" + ) + + $Extension = [System.IO.Path]::GetExtension($FilePath).ToLower() + if (Test-Path $FilePath) { Unblock-File -Path $FilePath } + + Write-PackagerLog -Message "Starting installation flow for: $Name ($Extension)" + + # --- ROUTING LOGIC --- + switch ($Extension) { + ".zip" { + Write-PackagerLog -Message "Detected ZIP. Extracting..." + $ZipName = [System.IO.Path]::GetFileNameWithoutExtension($FilePath) + $DestPath = Join-Path (Split-Path $FilePath) "Extracted_$ZipName" + if (Test-Path $DestPath) { Remove-Item $DestPath -Recurse -Force } + Expand-Archive -Path $FilePath -DestinationPath $DestPath -Force + + $Candidates = Get-ChildItem -Path $DestPath -Include *.exe,*.msi -Recurse + $Installer = $Candidates | Where-Object { $_.Name -match "setup" -or $_.Name -match "install" } | Select-Object -First 1 + if (-not $Installer) { $Installer = $Candidates | Sort-Object Length -Descending | Select-Object -First 1 } + if (-not $Installer) { throw "Extracted ZIP but could not find installer." } + + Write-PackagerLog -Message "Found installer inside ZIP: $($Installer.Name)" + Install-AppPackage -Name $Name -FilePath $Installer.FullName -Arguments $Arguments + return + } + { $_ -in ".msix", ".appx", ".msixbundle", ".appxbundle" } { + Write-PackagerLog -Message "Detected Modern App. Sideloading..." + try { + $PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx" + if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } + New-ItemProperty -Path $PolicyPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null + + $DevPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + if (-not (Test-Path $DevPath)) { New-Item -Path $DevPath -Force | Out-Null } + New-ItemProperty -Path $DevPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null + + Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "MSIX Provisioned Successfully." + } + catch { + Write-PackagerLog -Message "Provisioning Failed ($($_)). Trying Per-User..." -Severity Warning + try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + } + return + } + ".msi" { + Write-PackagerLog -Message "Detected MSI. Switching to msiexec." + $MsiPath = $FilePath + $FilePath = "msiexec.exe" + # Logic to ensure /i is prepended cleanly + if ($Arguments -is [string]) { $Arguments = "/i `"$MsiPath`" $Arguments" } + else { $Arguments = @("/i", $MsiPath) + $Arguments } + } + } + + # --- SAFE EXECUTION --- + # 1. Sanitize Arguments (The Fix for the Null Crash) + if ($null -eq $Arguments) { $Arguments = @() } + + if ($Arguments -is [string]) { + # Regex split that respects quotes + $Regex = ' (?=(?:[^"]*"[^"]*")*[^"]*$)' + $ArgList = [regex]::Split($Arguments, $Regex) + } else { + $ArgList = $Arguments + } + + # Filter out nulls/empties from the array + $ArgList = $ArgList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + Write-PackagerLog -Message "Executor: $FilePath" + Write-PackagerLog -Message "Final Args: $($ArgList -join ' | ')" + + try { + # If ArgList is empty, pass $null explicitly to avoid binding errors + if ($ArgList.Count -eq 0) { + $Process = Start-Process -FilePath $FilePath -PassThru -Wait -NoNewWindow + } else { + $Process = Start-Process -FilePath $FilePath -ArgumentList $ArgList -PassThru -Wait -NoNewWindow + } + + $ExitCode = $Process.ExitCode + Write-PackagerLog -Message "Finished. Exit Code: $ExitCode" + + switch ($ExitCode) { + 0 { Write-PackagerLog -Message "Success." } + 3010 { Write-PackagerLog -Message "Success (Reboot Required)." -Severity Warning } + 1641 { Write-PackagerLog -Message "Success (Hard Reboot Initiated)." -Severity Warning } + 4 { Write-PackagerLog -Message "Success (Reboot Required - Vendor Specific)." -Severity Warning } + default { throw "Failed with code $ExitCode" } + } + } + catch { + Write-PackagerLog -Message "Installation Failure: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-NableAgent.ps1 b/Public/Install-NableAgent.ps1 new file mode 100644 index 0000000..7ba9d31 --- /dev/null +++ b/Public/Install-NableAgent.ps1 @@ -0,0 +1,80 @@ +function Install-NableAgent { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$CustomerID, + [Parameter(Mandatory=$true)][string]$Token, + [Parameter(Mandatory=$true)][string]$CustomerName, + [Parameter(Mandatory=$true)][string]$ServerAddress, + [int]$TimeoutSeconds = 1200 # 20 minutes + ) + + $Name = "N-able RMM Agent" + $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" + $DownloadPath = "$env:TEMP\AppPackager" + $FileName = "Nable_RMMInstaller.exe" + $InstallerPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Starting N-able Deployment for Customer: $CustomerName" + + try { + # 1. Download + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + + Write-PackagerLog -Message "Downloading installer from: $DownloadUrl" + + # We use a spoofed UserAgent just in case N-able blocks scripts, though usually not required here. + $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + Invoke-WebRequest -Uri $DownloadUrl -OutFile $InstallerPath -UseBasicParsing -UserAgent $UserAgent + + if (-not (Test-Path $InstallerPath)) { throw "Download failed. File not found." } + + # 2. Build Arguments (Standard InstallShield format) + # /S = Silent for the wrapper + # /v = Pass arguments to internal MSI + # We quote the MSI arguments carefully so Install-AppPackage regex keeps them together. + + $MsiArgs = "/qn CUSTOMERID=$CustomerID CUSTOMERNAME=$CustomerName CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$Token SERVERPROTOCOL=HTTPS SERVERADDRESS=$ServerAddress SERVERPORT=443" + + # NOTE: We construct the final string. Install-AppPackage's Regex will see "/v"..." " as one argument. + $FinalArgs = "/S /v`"$MsiArgs`"" + + # 3. Install (Using your module's executor) + Install-AppPackage -Name $Name -FilePath $InstallerPath -Arguments $FinalArgs + + # 4. Cleanup Installer immediately + Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue + + # 5. Validation Loop (The "Is it actually working?" check) + Write-PackagerLog -Message "Starting Post-Install Validation (Timeout: ${TimeoutSeconds}s)..." + + $StartTime = Get-Date + + while ($true) { + # Check Services and Files + $Service1 = Get-Service -Name "Windows Agent Service" -ErrorAction SilentlyContinue + $Service2 = Get-Service -Name "N-able Take Control Service (N-Central)" -ErrorAction SilentlyContinue + $FileCheck = Test-Path "C:\Program Files (x86)\BeAnywhere Support Express\GetSupportService_N-Central\uninstall.exe" + + if ($Service1 -and $Service2 -and $FileCheck) { + Write-PackagerLog -Message "Validation Successful: N-able services are running." + return # Success! + } + + # Check Timeout + $Elapsed = (Get-Date) - $StartTime + if ($Elapsed.TotalSeconds -ge $TimeoutSeconds) { + Write-PackagerLog -Message "Validation Timed Out. Services did not start in time." -Severity Error + throw "N-able installation finished, but validation failed (Timeout)." + } + + Write-PackagerLog -Message "Waiting for services to start..." + Start-Sleep -Seconds 10 + } + + } + catch { + Write-PackagerLog -Message "N-able Deployment Failed: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-SmartApp.ps1 b/Public/Install-SmartApp.ps1 new file mode 100644 index 0000000..8f92967 --- /dev/null +++ b/Public/Install-SmartApp.ps1 @@ -0,0 +1,68 @@ +$ProgressPreference = 'SilentlyContinue' + +function Install-SmartApp { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Id + ) + + $Pkg = $null + + # --- ATTEMPT 1: GITHUB --- + try { + $Pkg = Get-GitHubInstaller -Id $Id -ErrorAction Stop + } + catch { + Write-PackagerLog -Message "Not found in GitHub ($Id). Checking Custom Catalog..." -Severity Info + } + + # --- ATTEMPT 2: CUSTOM CATALOG --- + if (-not $Pkg) { + # Load the internal helper to check JSON + # (Assuming Get-CustomApp is dot-sourced in .psm1) + $CustomData = Get-CustomApp -Id $Id + + if ($CustomData) { + Write-PackagerLog -Message "Found '$Id' in Custom Catalog." + + # Use the Web Installer logic to download it + # We can reuse the logic or call Get-WebInstaller if you created it. + # Here is the inline logic for simplicity: + + $DownloadPath = "$env:TEMP\AppPackager" + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + + $FileName = "$Id.$($CustomData.InstallerType)" + $FullPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Downloading Custom App from: $($CustomData.Url)" + Invoke-WebRequest -Uri $CustomData.Url -OutFile $FullPath -UseBasicParsing + + # Build the Package Object manually + $Pkg = [PSCustomObject]@{ + Name = $Id + InstallerPath = $FullPath + FileName = $FileName + SilentArgs = $CustomData.SilentArgs + InstallerType = $CustomData.InstallerType + } + } + } + + if (-not $Pkg) { + Write-PackagerLog -Message "Application '$Id' not found in GitHub OR Custom Catalog." -Severity Error + return + } + + # --- INSTALLATION --- + # MSI Fallback Logic + $Args = $Pkg.SilentArgs + if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { + $Args = "/qb /norestart" + } + + Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args + Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force +} \ No newline at end of file diff --git a/Public/Invoke-PackagerCleanup.ps1 b/Public/Invoke-PackagerCleanup.ps1 new file mode 100644 index 0000000..7b27a39 --- /dev/null +++ b/Public/Invoke-PackagerCleanup.ps1 @@ -0,0 +1,12 @@ +function Invoke-PackagerCleanup { + [CmdletBinding()] + param ([string[]]$Paths = @("$env:TEMP\AppPackager"), [switch]$Force) + process { + foreach ($Path in $Paths) { + if (Test-Path $Path) { + Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue + Write-PackagerLog -Message "Cleaned: $Path" + } + } + } +} diff --git a/Public/New-IntunePackage.ps1 b/Public/New-IntunePackage.ps1 new file mode 100644 index 0000000..32df464 --- /dev/null +++ b/Public/New-IntunePackage.ps1 @@ -0,0 +1,70 @@ +function New-IntunePackage { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Id, # e.g. "Dell.CommandUpdate" + + [string]$OutputFolder = "C:\IntunePackages" + ) + + # --- 1. SETUP --- + $PackagerTemp = "$env:TEMP\IntunePackager_Working\$Id" + $SourceDir = "$PackagerTemp\Source" + $IntuneUtil = "$env:TEMP\IntuneWinAppUtil.exe" + + # Clean Workspace + if (Test-Path $PackagerTemp) { Remove-Item $PackagerTemp -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -Path $SourceDir -ItemType Directory -Force | Out-Null + + # Ensure Output Dir + if (-not (Test-Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } + + # Ensure Intune Utility + if (-not (Test-Path $IntuneUtil)) { + Write-PackagerLog -Message "Downloading IntuneWinAppUtil.exe..." + Invoke-WebRequest "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/raw/master/IntuneWinAppUtil.exe" -OutFile $IntuneUtil + } + + # --- 2. GENERATE INSTALL.PS1 (The One-Liner) --- + $InstallScript = @" +# Install Trigger for $Id +`$ErrorActionPreference = 'Stop' + +# Try to load the module if not already loaded (Assumes it is installed on the PC) +Import-Module TecharyGet -ErrorAction SilentlyContinue + +Write-Host "Triggering Install for: $Id" +Install-SmartApp -Id "$Id" +"@ + Set-Content -Path "$SourceDir\Install.ps1" -Value $InstallScript + + # --- 3. GENERATE UNINSTALL.PS1 (The One-Liner) --- + $UninstallScript = @" +# Uninstall Trigger for $Id +`$ErrorActionPreference = 'Stop' + +Import-Module TecharyGet -ErrorAction SilentlyContinue + +Write-Host "Triggering Uninstall for: $Id" +Uninstall-SmartApp -Name "$Id" +"@ + Set-Content -Path "$SourceDir\Uninstall.ps1" -Value $UninstallScript + + # --- 4. PACKAGE IT --- + Write-PackagerLog -Message "Packaging scripts into .intunewin..." + + $Process = Start-Process -FilePath $IntuneUtil ` + -ArgumentList "-c `"$SourceDir`"", "-s `"Install.ps1`"", "-o `"$OutputFolder`"", "-q" ` + -PassThru -Wait -NoNewWindow + + if ($Process.ExitCode -eq 0) { + $Original = Join-Path $OutputFolder "Install.intunewin" + $Final = Join-Path $OutputFolder "$Id.intunewin" + if (Test-Path $Original) { Move-Item $Original $Final -Force } + + Write-PackagerLog -Message "SUCCESS: Package created at $Final" + Invoke-Item $OutputFolder + } else { + Write-PackagerLog -Message "Packaging Failed with Exit Code $($Process.ExitCode)" -Severity Error + } +} \ No newline at end of file diff --git a/Public/New-IntunePackageUI.ps1 b/Public/New-IntunePackageUI.ps1 new file mode 100644 index 0000000..46549f3 --- /dev/null +++ b/Public/New-IntunePackageUI.ps1 @@ -0,0 +1,93 @@ +function New-IntunePackageUI { + [CmdletBinding()] + param() + + # Load Windows Forms + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + # --- UI SETUP --- + $Form = New-Object System.Windows.Forms.Form + $Form.Text = "Techary Intune Packager (Lite)" + $Form.Size = New-Object System.Drawing.Size(500, 300) + $Form.StartPosition = "CenterScreen" + $Form.FormBorderStyle = "FixedDialog" + $Form.MaximizeBox = $false + + # -- App ID Field -- + $LblId = New-Object System.Windows.Forms.Label + $LblId.Text = "Application ID (e.g. 7zip.7zip):" + $LblId.Location = New-Object System.Drawing.Point(20, 20) + $LblId.Size = New-Object System.Drawing.Size(400, 20) + $Form.Controls.Add($LblId) + + $TxtId = New-Object System.Windows.Forms.TextBox + $TxtId.Location = New-Object System.Drawing.Point(20, 45) + $TxtId.Size = New-Object System.Drawing.Size(440, 25) + $Form.Controls.Add($TxtId) + + # -- Output Folder Field -- + $LblOut = New-Object System.Windows.Forms.Label + $LblOut.Text = "Output Folder:" + $LblOut.Location = New-Object System.Drawing.Point(20, 90) + $LblOut.Size = New-Object System.Drawing.Size(400, 20) + $Form.Controls.Add($LblOut) + + $TxtOut = New-Object System.Windows.Forms.TextBox + $TxtOut.Text = "C:\IntunePackages" # Default + $TxtOut.Location = New-Object System.Drawing.Point(20, 115) + $TxtOut.Size = New-Object System.Drawing.Size(350, 25) + $Form.Controls.Add($TxtOut) + + $BtnBrowse = New-Object System.Windows.Forms.Button + $BtnBrowse.Text = "..." + $BtnBrowse.Location = New-Object System.Drawing.Point(380, 114) + $BtnBrowse.Size = New-Object System.Drawing.Size(80, 27) + $BtnBrowse.Add_Click({ + $Dialog = New-Object System.Windows.Forms.FolderBrowserDialog + if ($Dialog.ShowDialog() -eq "OK") { $TxtOut.Text = $Dialog.SelectedPath } + }) + $Form.Controls.Add($BtnBrowse) + + # -- Create Button -- + $BtnRun = New-Object System.Windows.Forms.Button + $BtnRun.Text = "CREATE PACKAGE" + $BtnRun.Location = New-Object System.Drawing.Point(20, 170) + $BtnRun.Size = New-Object System.Drawing.Size(440, 50) + $BtnRun.BackColor = [System.Drawing.Color]::CornflowerBlue + $BtnRun.ForeColor = [System.Drawing.Color]::White + $BtnRun.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) + + $BtnRun.Add_Click({ + $Id = $TxtId.Text + $Out = $TxtOut.Text + + if (-not $Id) { + [System.Windows.Forms.MessageBox]::Show("Please enter an App ID.", "Error", "OK", "Warning") + return + } + + $BtnRun.Text = "Packaging..." + $BtnRun.Enabled = $false + $Form.Update() + + try { + # Call the backend function we created earlier + New-IntunePackage -Id $Id -OutputFolder $Out + + [System.Windows.Forms.MessageBox]::Show("Package Created Successfully!", "Success", "OK", "Information") + } + catch { + [System.Windows.Forms.MessageBox]::Show("Error: $_", "Failed", "OK", "Error") + } + finally { + $BtnRun.Text = "CREATE PACKAGE" + $BtnRun.Enabled = $true + } + }) + $Form.Controls.Add($BtnRun) + + # Show + $Form.ShowDialog() | Out-Null + $Form.Dispose() +} \ No newline at end of file diff --git a/Public/Uninstall-SmartApp.ps1 b/Public/Uninstall-SmartApp.ps1 new file mode 100644 index 0000000..83609e7 --- /dev/null +++ b/Public/Uninstall-SmartApp.ps1 @@ -0,0 +1,111 @@ +function Uninstall-SmartApp { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Name, + + [switch]$WhatIf + ) + + Write-PackagerLog -Message "Searching for installed application: $Name" + + # 1. SEARCH REGISTRY (Classic Apps) + $Paths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $App = $null + foreach ($Path in $Paths) { + $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "*$Name*" } | + Select-Object -First 1 + if ($App) { break } + } + + # 2. IF NOT IN REGISTRY, CHECK MSIX (Modern Apps) + if (-not $App) { + Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." + $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue + + if ($MsixResults) { + # FIX: Handle cases where multiple apps match (Array vs Single Object) + foreach ($Package in $MsixResults) { + Write-PackagerLog -Message "Found Modern App: $($Package.Name)" + + if ($WhatIf) { + Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow + continue + } + + try { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name)" + } + catch { + Write-PackagerLog -Message "Failed to remove $($Package.Name): $_" -Severity Error + } + } + return + } + + Write-PackagerLog -Message "Application '$Name' not found on this system." -Severity Warning + return + } + + # 3. DETERMINE UNINSTALL COMMAND (Classic Apps) + $UninstallString = $null + $Type = "EXE" + + if ($App.UninstallString -match "MsiExec.exe") { + $Type = "MSI" + if ($App.UninstallString -match '{[A-F0-9-]+}') { + $Guid = $Matches[0] + $UninstallString = "msiexec.exe" + $Arguments = "/x $Guid /qn /norestart" + } + } + else { + # EXE Uninstaller logic + if ($App.QuietUninstallString) { + $RawString = $App.QuietUninstallString + } else { + $RawString = $App.UninstallString + } + + if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { + $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + $ArgsPart = $Matches[3].Trim() + + $UninstallString = $Exe + $Arguments = $ArgsPart + + if (-not ($Arguments -match "/S|/silent|/qn|/quiet")) { + $Arguments = "$Arguments /S /silent /quiet /norestart" + } + } + } + + Write-PackagerLog -Message "Found: $($App.DisplayName) ($Type)" + Write-PackagerLog -Message "Command: $UninstallString $Arguments" + + if ($WhatIf) { + Write-Host "[WhatIf] Would execute: $UninstallString $Arguments" -ForegroundColor Yellow + return + } + + # 4. EXECUTE REMOVAL + try { + $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow + + if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { + Write-PackagerLog -Message "Uninstallation Successful." + } else { + Write-PackagerLog -Message "Uninstallation finished with Exit Code: $($Process.ExitCode)" -Severity Warning + } + } + catch { + Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error + } +} \ No newline at end of file diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 new file mode 100644 index 0000000..ed2d4ee --- /dev/null +++ b/Public/Write-PackagerLog.ps1 @@ -0,0 +1,37 @@ +function Write-PackagerLog { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$Message, + [ValidateSet("Info", "Warning", "Error")][string]$Severity = "Info" + ) + + $LogPath = "$env:ProgramData\TecharyGet\InstallLogs.log" + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $Line = "[$Timestamp] [$Severity] $Message" + + # 1. Console Output + $Color = switch ($Severity) { "Info" {"Green"} "Warning" {"Yellow"} "Error" {"Red"} } + Write-Host $Line -ForegroundColor $Color + + # 2. File Log + if (-not (Test-Path (Split-Path $LogPath))) { New-Item -ItemType Directory (Split-Path $LogPath) -Force | Out-Null } + Add-Content -Path $LogPath -Value $Line + + # 3. ENTERPRISE EVENT LOGGING (New!) + # N-able can pick this up easily. + # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) + + $EventSource = "TecharyGet" + if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { + # Requires Admin to create source once. + # If not admin, this skips silently to avoid crashing. + try { New-EventLog -LogName Application -Source $EventSource -ErrorAction SilentlyContinue } catch {} + } + + if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { + $EventID = switch ($Severity) { "Info" {100} "Warning" {200} "Error" {300} } + $EntryType = switch ($Severity) { "Info" {"Information"} "Warning" {"Warning"} "Error" {"Error"} } + + Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message + } +} \ No newline at end of file From a010d5e0621fed7133497cdde2505d905908c985 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:35:36 +0000 Subject: [PATCH 06/50] Revise README.md for TecharyGet module details Updated the README.md to include comprehensive module features and usage examples. --- README.md | 224 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 128 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index fe48db8..e8fae0d 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,175 @@ -ChatGPT Image Oct 30, 2025, 09_09_28 AM +Here is a professional, comprehensive `README.md` file for your module. It covers all the advanced features we built (Cloud Catalog, Intune Packaging, Smart Uninstall, etc.). -# TecharyGet PowerShell Module +You can copy-paste this directly into the root of your project. -> **Author:** Adam Sweetapple +--- -> **Purpose:** Install and uninstall software using custom Winget logic and external installer definitions (including EXE, MSI, ZIP, MSIX). +```markdown +# TecharyGet -## Features +**TecharyGet** is an enterprise-grade PowerShell module designed for modern software deployment. It bridges the gap between ad-hoc installations and formal Intune packaging, allowing IT Admins to deploy the latest versions of software dynamically without maintaining massive local repositories. -* Installs apps from Winget using GitHub-hosted YAMLs +## 🚀 Key Features -* Supports MSI, EXE, ZIP, and MSIX installers +* **Smart Installation:** Automatically fetches the latest version of apps from GitHub (WinGet Manifests) or your Private Cloud Catalog. +* **Architecture Detection:** Automatically selects the correct installer (x64, x86, ARM64) for the target machine. +* **Enterprise Logging:** Writes detailed logs to both `C:\ProgramData\TecharyGet\InstallLogs.log` and the **Windows Event Log** (Source: `TecharyGet`) for RMM monitoring. +* **Intune Packaging:** Instantly generates `.intunewin` files with "Thin" scripts that trigger installs dynamically. +* **Smart Uninstall:** Removes apps by searching Registry (MSI/EXE), Modern Apps (MSIX), and Custom Display Names. +* **Private Catalog:** Supports a cloud-hosted `CustomApps.json` (e.g., GitHub Raw) for proprietary apps not found in public repos. -* Custom app support with static URLs and parameters (e.g. N-Able, myDPD) +--- -* Works with Intune deployments and SYSTEM-level context +## 📦 Installation -* Full uninstall logic via Winget or Registry fallback +1. Copy the `TecharyGet` folder to your PowerShell Modules directory: + * `C:\Program Files\WindowsPowerShell\Modules\` +2. Import the module: + ```powershell + Import-Module TecharyGet -Force + ``` -* Architecture-aware (x64, ARM64) +--- -## Available Commands -**Install an App** -```Powershell -Install-TecharyApp -AppName "7zip" -``` +## 🛠️ Usage Examples -**Install with Parameters (e.g. N-Able)** -```Powershell -Install-TecharyApp -AppName "nable" -Parameters @{ - CustomerID = "123" - Token = "abcdef-12345" - CustomerName = '\"customer name\"' - ServerAddress = "nable.serveraddress.com" -} -``` -**Update TecharyGet Module** +### 1. Installing Applications +The `Install-SmartApp` command is the primary workhorse. It attempts to find the app in the public GitHub repo first, then falls back to your Private Catalog. + +```powershell +# Install a standard app (Latest Version) +Install-SmartApp -Id "7zip.7zip" + +# Install a specific ID from your private catalog +Install-SmartApp -Id "MyDPD" + +# Install a complex app (e.g. Dell Command Update) - Handles Exit Code 4 automatically +Install-SmartApp -Id "Dell.CommandUpdate" -To get the latest TecharyGet Module, please run the following: -```Powershell -Update-TecharyGetModule -``` -**Uninstall an App** -```Powershell -Uninstall-TecharyApp -AppName "bitwarden" ``` -**List All Supported Apps** -```Powershell -Get-TecharyAppList + +### 2. Uninstalling Applications + +The uninstaller intelligently searches the Registry (HKLM/HKCU) and Appx packages. + +```powershell +# Uninstall by Display Name +Uninstall-SmartApp -Name "Google Chrome" + +# Uninstall using a Custom ID (looks up real name in JSON) +Uninstall-SmartApp -Name "MyDPD" + +# Test run (Safe Mode) +Uninstall-SmartApp -Name "Windows365" -WhatIf + ``` -🔧 Example Output of Get-TecharyAppList -| AppKey | DisplayName | InstallerType | IsWinget | WingetID | -| ----- | ---------- | ------------- | -------- | -------- | -| 7zip | 7Zip | exe | true | 7zip.7zip | -| bitwarden | Bitwarden | exe | true | Bitwarden.Bitwarden | -| vscode | Microsoft Visual Studio Code | exe | true | Microsoft.VisualStudioCode | -| nable | N-Able RMM | exe | false | (custom) | -| powerbi | Microsoft Power BI | exe | true | Microsoft.PowerBI | - -**Show Help** -```Powershell -Help-TecharyApp +### 3. Creating Intune Packages + +You can generate ready-to-upload `.intunewin` files that contain "Thin" scripts (One-Liners). These scripts assume `TecharyGet` is installed on the target machine and trigger a live download/install. + +**GUI Method:** + +```powershell +New-IntunePackageUI + ``` -## AppMap Configuration - -Apps are defined in a separate file, AppMap.ps1, hosted in the GitHub Repository. - -The following structure lists the available Winget apps: -``` Powershell -"bitwarden" = @{ - DisplayName = "Bitwarden" - RepoPath = "b/Bitwarden/Bitwarden" - YamlFile = "Bitwarden.Bitwarden.installer.yaml" - PatternX64 = 'InstallerUrl:\s*(\S*/Bitwarden-Installer-\S+\.exe)' - PatternARM64 = 'InstallerUrl:\s*(\S*/Bitwarden-Installer-\S+\.exe)' - InstallerType = "exe" - ExeInstallArgs = "/allusers /S" - IsWinget = $true - WingetID = "Bitwarden.Bitwarden" -} +**CLI Method:** + +```powershell +New-IntunePackage -Id "Dell.CommandUpdate" -OutputFolder "C:\IntunePackages" + ``` -For custom apps that are not available in Winget are structured similar like this: -```Powershell -"mydpd" = @{ - DisplayName = "MyDPD Customer" - IsWinget = $false - DownloadUrl = "https://apis.my.dpd.co.uk/apps/download/public" - InstallerType = "exe" - ExeInstallArgs = "--Silent" -} +* **Install Command:** `powershell.exe -ExecutionPolicy Bypass -File Install.ps1` +* **Uninstall Command:** `powershell.exe -ExecutionPolicy Bypass -File Uninstall.ps1` + +### 4. Special N-able Agent Deployment + +N-able requires dynamic parameters (Token, CustomerID). Use the dedicated function: + +```powershell +Install-NableAgent -CustomerID "123" ` + -Token "abc-123-xyz" ` + -CustomerName "ClientA" ` + -ServerAddress "rmm.example.com" + ``` -## Intune Packager +--- + +## ☁️ Private Catalog Configuration -The **Intune-Packager.ps1** allows the ease of creation of Intunewin files for Intune upload and App deployment. +For apps that are not in the public WinGet repo (e.g., licensed software, internal tools), use the **Private Catalog**. -To use, download the **Intune-Packager.ps1** file, right-click and Run with Powershell, you will be given the below window. +### 1. The JSON Structure -image +Create a `CustomApps.json` file: +```json +[ + { + "Id": "MyDPD", + "Url": "[https://www.mydpd.co.uk/downloads/MyDPD_Installer.exe](https://www.mydpd.co.uk/downloads/MyDPD_Installer.exe)", + "SilentArgs": "/S /v/qn", + "InstallerType": "exe", + "DisplayName": "MyDPD Desktop Client" + }, + { + "Id": "InternalTool", + "Url": "[https://storage.mycompany.com/tools/internal.msi](https://storage.mycompany.com/tools/internal.msi)", + "SilentArgs": "/qb", + "InstallerType": "msi", + "DisplayName": "Techary Internal Tool v2" + } +] -Select your needed app on the drop-down, and click **Create IntuneWin Package**, this will create the Intunewin package in this location **"C:\IntuneApps\Output\** *i.e. C:\IntuneApps\Output\adobereader*. +``` -image +### 2. Hosting (Cloud Sync) -For N-Able install you will see the parameters needed to create the Customer specific installer: +Host this file on a raw URL (e.g., GitHub Raw, Azure Blob). The module automatically caches this file locally and syncs it every 60 minutes. -image +**To configure the URL:** +Edit `Private\Get-CustomApp.ps1` and update the `$CloudUrl` variable: +```powershell +$CloudUrl = "[https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json](https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json)" +``` +--- -## Notes +## 🔍 Logging & Telemetry -* The module detects CPU architecture and installs the correct version. +The module provides full observability for RMM tools (N-able, Datto, NinjaOne). -* All downloads are logged to C:\Logs\TecharyGetLogs\TecharyGet.log +* **Log File:** `C:\ProgramData\TecharyGet\InstallLogs.log` +* **Event Log:** Windows Logs -> Application +* **Source:** `TecharyGet` +* **Event IDs:** +* `100`: Information (Started, Success) +* `200`: Warning (Reboot Required, Retry) +* `300`: Error (Download Failed, Install Failed) -* Apps not in Winget can be defined with a static DownloadUrl and installed with logic from the module. -* You can run winget.exe directly (e.g. for SYSTEM context via Intune) using its resolved path in C:\Program Files\WindowsApps\... -* Hosting the AppMap.ps1 file means that we can manage all app installs from a centralised location for ALL of out customers. -## 🧪 Tested With -* Intune deployments (System context) +--- -* Windows 10/11 x64 + ARM64 +## ⚠️ Troubleshooting -* PowerShell 5.1 and 7+ +**"Access Denied" on Download:** +The module uses User-Agent spoofing (Chrome/120) to bypass Akamai/Vendor blocks (e.g., Dell, Adobe). -## Troubleshooting +**Exit Code 4 (Dell):** +The module treats Exit Code `4` as "Success (Reboot Required)" specifically for Dell installers, preventing false failure reports. -* ❗ App not found? → Make sure it's defined in AppMap.ps1 +**"WindowsApp" Uninstall Failures:** +Store Apps often have internal names different from their display names. Use `Get-StartApps` to find the internal ID (e.g., `MicrosoftCorporationII.Windows365`). -* ❗ Duplicate key error? → Ensure there are no repeated properties in app maps (like IsWinget or WingetID) +``` -* ❗ Winget not running in SYSTEM? → Use the direct winget.exe path resolution +``` From 75da1dd2bba0d521a4497379bed7332766541daf Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:36:02 +0000 Subject: [PATCH 07/50] Revise README by removing outdated sections Removed introductory text and several sections about uninstalling applications, creating Intune packages, special N-able agent deployment, private catalog configuration, logging, telemetry, and troubleshooting. --- README.md | 135 ------------------------------------------------------ 1 file changed, 135 deletions(-) diff --git a/README.md b/README.md index e8fae0d..fa5bce0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,3 @@ -Here is a professional, comprehensive `README.md` file for your module. It covers all the advanced features we built (Cloud Catalog, Intune Packaging, Smart Uninstall, etc.). - -You can copy-paste this directly into the root of your project. - ---- - -```markdown # TecharyGet **TecharyGet** is an enterprise-grade PowerShell module designed for modern software deployment. It bridges the gap between ad-hoc installations and formal Intune packaging, allowing IT Admins to deploy the latest versions of software dynamically without maintaining massive local repositories. @@ -45,131 +38,3 @@ Install-SmartApp -Id "MyDPD" # Install a complex app (e.g. Dell Command Update) - Handles Exit Code 4 automatically Install-SmartApp -Id "Dell.CommandUpdate" - -``` - -### 2. Uninstalling Applications - -The uninstaller intelligently searches the Registry (HKLM/HKCU) and Appx packages. - -```powershell -# Uninstall by Display Name -Uninstall-SmartApp -Name "Google Chrome" - -# Uninstall using a Custom ID (looks up real name in JSON) -Uninstall-SmartApp -Name "MyDPD" - -# Test run (Safe Mode) -Uninstall-SmartApp -Name "Windows365" -WhatIf - -``` - -### 3. Creating Intune Packages - -You can generate ready-to-upload `.intunewin` files that contain "Thin" scripts (One-Liners). These scripts assume `TecharyGet` is installed on the target machine and trigger a live download/install. - -**GUI Method:** - -```powershell -New-IntunePackageUI - -``` - -**CLI Method:** - -```powershell -New-IntunePackage -Id "Dell.CommandUpdate" -OutputFolder "C:\IntunePackages" - -``` - -* **Install Command:** `powershell.exe -ExecutionPolicy Bypass -File Install.ps1` -* **Uninstall Command:** `powershell.exe -ExecutionPolicy Bypass -File Uninstall.ps1` - -### 4. Special N-able Agent Deployment - -N-able requires dynamic parameters (Token, CustomerID). Use the dedicated function: - -```powershell -Install-NableAgent -CustomerID "123" ` - -Token "abc-123-xyz" ` - -CustomerName "ClientA" ` - -ServerAddress "rmm.example.com" - -``` - ---- - -## ☁️ Private Catalog Configuration - -For apps that are not in the public WinGet repo (e.g., licensed software, internal tools), use the **Private Catalog**. - -### 1. The JSON Structure - -Create a `CustomApps.json` file: - -```json -[ - { - "Id": "MyDPD", - "Url": "[https://www.mydpd.co.uk/downloads/MyDPD_Installer.exe](https://www.mydpd.co.uk/downloads/MyDPD_Installer.exe)", - "SilentArgs": "/S /v/qn", - "InstallerType": "exe", - "DisplayName": "MyDPD Desktop Client" - }, - { - "Id": "InternalTool", - "Url": "[https://storage.mycompany.com/tools/internal.msi](https://storage.mycompany.com/tools/internal.msi)", - "SilentArgs": "/qb", - "InstallerType": "msi", - "DisplayName": "Techary Internal Tool v2" - } -] - -``` - -### 2. Hosting (Cloud Sync) - -Host this file on a raw URL (e.g., GitHub Raw, Azure Blob). The module automatically caches this file locally and syncs it every 60 minutes. - -**To configure the URL:** -Edit `Private\Get-CustomApp.ps1` and update the `$CloudUrl` variable: - -```powershell -$CloudUrl = "[https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json](https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json)" - -``` - ---- - -## 🔍 Logging & Telemetry - -The module provides full observability for RMM tools (N-able, Datto, NinjaOne). - -* **Log File:** `C:\ProgramData\TecharyGet\InstallLogs.log` -* **Event Log:** Windows Logs -> Application -* **Source:** `TecharyGet` -* **Event IDs:** -* `100`: Information (Started, Success) -* `200`: Warning (Reboot Required, Retry) -* `300`: Error (Download Failed, Install Failed) - - - - - ---- - -## ⚠️ Troubleshooting - -**"Access Denied" on Download:** -The module uses User-Agent spoofing (Chrome/120) to bypass Akamai/Vendor blocks (e.g., Dell, Adobe). - -**Exit Code 4 (Dell):** -The module treats Exit Code `4` as "Success (Reboot Required)" specifically for Dell installers, preventing false failure reports. - -**"WindowsApp" Uninstall Failures:** -Store Apps often have internal names different from their display names. Use `Get-StartApps` to find the internal ID (e.g., `MicrosoftCorporationII.Windows365`). - -``` - -``` From b28813c00406cc8e9e385e2a3942c330a74d7c40 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:43:28 +0000 Subject: [PATCH 08/50] Update README to streamline installation and usage Removed import instructions and simplified usage examples. --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index fa5bce0..381ccd3 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,6 @@ 1. Copy the `TecharyGet` folder to your PowerShell Modules directory: * `C:\Program Files\WindowsPowerShell\Modules\` -2. Import the module: - ```powershell - Import-Module TecharyGet -Force - ``` - --- ## 🛠️ Usage Examples @@ -35,6 +30,3 @@ Install-SmartApp -Id "7zip.7zip" # Install a specific ID from your private catalog Install-SmartApp -Id "MyDPD" - -# Install a complex app (e.g. Dell Command Update) - Handles Exit Code 4 automatically -Install-SmartApp -Id "Dell.CommandUpdate" From b6503aba87a612fc516b6b71ce35c024bdb74a1d Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:10:49 +0000 Subject: [PATCH 09/50] Add Install-TecharyApp function for app installation --- Public/Install-SmartApp.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Public/Install-SmartApp.ps1 b/Public/Install-SmartApp.ps1 index 8f92967..6329d9e 100644 --- a/Public/Install-SmartApp.ps1 +++ b/Public/Install-SmartApp.ps1 @@ -1,6 +1,6 @@ $ProgressPreference = 'SilentlyContinue' -function Install-SmartApp { +function Install-TecharyApp { [CmdletBinding()] param( [Parameter(Mandatory=$true)] @@ -65,4 +65,5 @@ function Install-SmartApp { Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force -} \ No newline at end of file + +} From a722e949ac60550647faa5cd4606a6d9e9b4f8da Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:11:23 +0000 Subject: [PATCH 10/50] Rename Install-SmartApp.ps1 to Install-TecharyApp.ps1 --- Public/{Install-SmartApp.ps1 => Install-TecharyApp.ps1} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Public/{Install-SmartApp.ps1 => Install-TecharyApp.ps1} (100%) diff --git a/Public/Install-SmartApp.ps1 b/Public/Install-TecharyApp.ps1 similarity index 100% rename from Public/Install-SmartApp.ps1 rename to Public/Install-TecharyApp.ps1 From d59103c626acd5e7360cfcf115e41fb4e4d22845 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:11:47 +0000 Subject: [PATCH 11/50] Add Uninstall-TecharyApp function for app removal --- Public/{Uninstall-SmartApp.ps1 => Uninstall-TecharyApp.ps1} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename Public/{Uninstall-SmartApp.ps1 => Uninstall-TecharyApp.ps1} (96%) diff --git a/Public/Uninstall-SmartApp.ps1 b/Public/Uninstall-TecharyApp.ps1 similarity index 96% rename from Public/Uninstall-SmartApp.ps1 rename to Public/Uninstall-TecharyApp.ps1 index 83609e7..20d0c70 100644 --- a/Public/Uninstall-SmartApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -1,4 +1,4 @@ -function Uninstall-SmartApp { +function Uninstall-TecharyApp { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] @@ -108,4 +108,5 @@ function Uninstall-SmartApp { catch { Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error } -} \ No newline at end of file + +} From 0592b692a71207a7cb9af20f1912e6c16eece537 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:12:29 +0000 Subject: [PATCH 12/50] Rename Install-SmartApp to Install-TecharyApp --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 381ccd3..ae4da47 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ ## 🛠️ Usage Examples ### 1. Installing Applications -The `Install-SmartApp` command is the primary workhorse. It attempts to find the app in the public GitHub repo first, then falls back to your Private Catalog. +The `Install-TecharyApp` command is the primary workhorse. It attempts to find the app in the public GitHub repo first, then falls back to your Private Catalog. ```powershell # Install a standard app (Latest Version) -Install-SmartApp -Id "7zip.7zip" +Install-TecharyApp -Id "7zip.7zip" # Install a specific ID from your private catalog -Install-SmartApp -Id "MyDPD" +Install-TecharyApp -Id "MyDPD" From f094a9898830e21c4e8a6990a4ae977f7f8b9474 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:14:22 +0000 Subject: [PATCH 13/50] Update Install-TecharyApp function for MSI fallback --- Public/Install-TecharyApp.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 index 6329d9e..5b7362e 100644 --- a/Public/Install-TecharyApp.ps1 +++ b/Public/Install-TecharyApp.ps1 @@ -60,10 +60,11 @@ function Install-TecharyApp { # MSI Fallback Logic $Args = $Pkg.SilentArgs if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { - $Args = "/qb /norestart" + $Args = "ALLUSERS=1 /quiet /norestart" } Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force } + From dfd243ccc3f5da29c5c69d429e54d2ed3f5b0b56 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:15:21 +0000 Subject: [PATCH 14/50] Refactor TecharyGet.psm1 for consistency --- AppPackager.psm1 => TecharyGet.psm1 | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename AppPackager.psm1 => TecharyGet.psm1 (100%) diff --git a/AppPackager.psm1 b/TecharyGet.psm1 similarity index 100% rename from AppPackager.psm1 rename to TecharyGet.psm1 From ed4e88eb46fe3228f16223409e80085ba3f365de Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:19:06 +0000 Subject: [PATCH 15/50] Update module manifest for TecharyGet --- AppPackager.psd1 => TecharyGet.psd1 | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) rename AppPackager.psd1 => TecharyGet.psd1 (91%) diff --git a/AppPackager.psd1 b/TecharyGet.psd1 similarity index 91% rename from AppPackager.psd1 rename to TecharyGet.psd1 index 7463d6e..3aa76c0 100644 --- a/AppPackager.psd1 +++ b/TecharyGet.psd1 @@ -1,7 +1,7 @@ # -# Module manifest for module 'AppPackager' +# Module manifest for module 'TecharyGet' # -# Generated by: asweetapple +# Generated by: Adam Sweetapple # # Generated on: 04/02/2026 # @@ -9,10 +9,10 @@ @{ # Script module or binary module file associated with this manifest. -RootModule = 'AppPackager.psm1' +RootModule = 'TecharyGet.psm1' # Version number of this module. -ModuleVersion = '0.0.1' +ModuleVersion = '0.1' # Supported PSEditions # CompatiblePSEditions = @() @@ -21,13 +21,13 @@ ModuleVersion = '0.0.1' GUID = '9f3ce1a8-1494-4b4a-b885-0c1112fc234e' # Author of this module -Author = 'asweetapple' +Author = 'Adam Sweetapple' # Company or vendor of this module -CompanyName = 'Unknown' +CompanyName = 'Techary' # Copyright statement for this module -Copyright = '(c) asweetapple. All rights reserved.' +Copyright = '(c) Adam Sweetapple. All rights reserved.' # Description of the functionality provided by this module # Description = '' @@ -131,3 +131,4 @@ PrivateData = @{ } + From 4d9c0fe1e2715b8c433ca1df92057f049b106022 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:22:59 +0000 Subject: [PATCH 16/50] Update TecharyGet module manifest with new functions Added new functions for managing Techary applications. --- TecharyGet.psd1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 3aa76c0..9e3097f 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -70,7 +70,9 @@ Copyright = '(c) Adam Sweetapple. All rights reserved.' # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = 'Get-GitHubInstaller', 'Install-AppPackage', 'Write-PackagerLog', - 'Invoke-PackagerCleanup', 'Install-GitHubApp' + 'Invoke-PackagerCleanup', 'Install-GitHubApp', 'Install-TecharyApp', + 'New-IntunePackage', 'New-IntunePackageUI', 'Uninstall-TecharyGet', + 'Install-NableAgent', 'Invoke-PackagerCleanup', 'Write-PackagerLog' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = '*' @@ -132,3 +134,4 @@ PrivateData = @{ } + From 315ba936b053f19dc8edbffaf02c72a9086cf597 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:52:10 +0000 Subject: [PATCH 17/50] Update TecharyGet.psd1 --- TecharyGet.psd1 | 182 +++++++++++++++++++----------------------------- 1 file changed, 73 insertions(+), 109 deletions(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 9e3097f..9f0c8fe 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -1,137 +1,101 @@ -# -# Module manifest for module 'TecharyGet' -# -# Generated by: Adam Sweetapple -# -# Generated on: 04/02/2026 -# - @{ + # Script module or binary module file associated with this manifest. + RootModule = 'TecharyGet.psm1' -# Script module or binary module file associated with this manifest. -RootModule = 'TecharyGet.psm1' - -# Version number of this module. -ModuleVersion = '0.1' - -# Supported PSEditions -# CompatiblePSEditions = @() - -# ID used to uniquely identify this module -GUID = '9f3ce1a8-1494-4b4a-b885-0c1112fc234e' - -# Author of this module -Author = 'Adam Sweetapple' - -# Company or vendor of this module -CompanyName = 'Techary' - -# Copyright statement for this module -Copyright = '(c) Adam Sweetapple. All rights reserved.' - -# Description of the functionality provided by this module -# Description = '' - -# Minimum version of the PowerShell engine required by this module -# PowerShellVersion = '' - -# Name of the PowerShell host required by this module -# PowerShellHostName = '' + # Version number of this module. + ModuleVersion = '0.1' -# Minimum version of the PowerShell host required by this module -# PowerShellHostVersion = '' + # ID used to uniquely identify this module + GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' -# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. -# DotNetFrameworkVersion = '' + # Author of this module + Author = 'Adam Sweetapple' -# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. -# ClrVersion = '' + # Company or vendor of this module + CompanyName = 'Techary' -# Processor architecture (None, X86, Amd64) required by this module -# ProcessorArchitecture = '' + # Copyright statement for this module + Copyright = '(c) 2026 Techary. All rights reserved.' -# Modules that must be imported into the global environment prior to importing this module -# RequiredModules = @() + # Description of the functionality provided by this module + Description = 'A PowerShell module for managing app installations and uninstalls using Winget Repo, MSI, EXE, ZIP, and MSIX sources. Supports custom logic and Intune deployment.' -# Assemblies that must be loaded prior to importing this module -# RequiredAssemblies = @() + # Functions to export from this module, for best performance, do not use wildcards. + FunctionsToExport = @( + # -- Core Installation -- + 'Install-TecharyApp', + 'Uninstall-TecharyApp', + 'Test-TecharyApp', # The new Detection Logic + + # -- Specific Installers -- + 'Install-NableAgent', # The custom RMM installer + 'Get-GitHubInstaller', # Useful for manual manifest checking -# Script files (.ps1) that are run in the caller's environment prior to importing this module. -# ScriptsToProcess = @() + # -- Intune Packaging Tools -- + 'New-IntunePackage', # The CLI Packager (with Detect/Uninstall generation) + 'New-IntunePackageUI', # The GUI Packager + 'Show-IntunePackager', # The Wrapper for the MS Utility -# Type files (.ps1xml) to be loaded when importing this module -# TypesToProcess = @() + # -- Utilities -- + 'Write-PackagerLog' + ) -# Format files (.ps1xml) to be loaded when importing this module -# FormatsToProcess = @() + # Cmdlets to export from this module + CmdletsToExport = @() -# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess -# NestedModules = @() + # Variables to export from this module + VariablesToExport = '*' -# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = 'Get-GitHubInstaller', 'Install-AppPackage', 'Write-PackagerLog', - 'Invoke-PackagerCleanup', 'Install-GitHubApp', 'Install-TecharyApp', - 'New-IntunePackage', 'New-IntunePackageUI', 'Uninstall-TecharyGet', - 'Install-NableAgent', 'Invoke-PackagerCleanup', 'Write-PackagerLog' + # Aliases to export from this module + AliasesToExport = @() -# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = '*' + # List of all modules packaged with this module + # NestedModules = @() -# Variables to export from this module -VariablesToExport = '*' + # List of all files packaged with this module + # FileList = @() -# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = '*' + # Private data to pass to the module specified in RootModule/ModuleToProcess + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('Intune', 'PackageManagement', 'Install', 'Uninstall', 'Winget', 'RMM', 'Automation') + + # A URL to the license for this module. + # LicenseUri = '' -# DSC resources to export from this module -# DscResourcesToExport = @() + # A URL to the main website for this project. + # ProjectUri = '' -# List of all modules packaged with this module -# ModuleList = @() + # A URL to an icon representing this module. + # IconUri = '' -# List of all files packaged with this module -# FileList = @() + # ReleaseNotes of this module + # ReleaseNotes = '' + } + } -# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. -PrivateData = @{ + # Minimum version of the Windows PowerShell engine required by this module + PowerShellVersion = '5.1' - PSData = @{ + # Minimum version of the Common Language Runtime (CLR) required by this module + # CLRVersion = '' - # Tags applied to this module. These help with module discovery in online galleries. - # Tags = @() + # Processor architecture (None, X86, Amd64) required by this module + # ProcessorArchitecture = '' - # A URL to the license for this module. - # LicenseUri = '' + # Modules that must be imported into the global environment prior to importing this module + # RequiredModules = @() - # A URL to the main website for this project. - # ProjectUri = '' + # Assemblies that must be loaded prior to importing this module + # RequiredAssemblies = @() - # A URL to an icon representing this module. - # IconUri = '' + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + # ScriptsToProcess = @() - # ReleaseNotes of this module - # ReleaseNotes = '' - - # Prerelease string of this module - # Prerelease = '' - - # Flag to indicate whether the module requires explicit user acceptance for install/update/save - # RequireLicenseAcceptance = $false - - # External dependent modules of this module - # ExternalModuleDependencies = @() - - } # End of PSData hashtable - -} # End of PrivateData hashtable - -# HelpInfo URI of this module -# HelpInfoURI = '' - -# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. -# DefaultCommandPrefix = '' + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @() + # Format files (.ps1xml) to be loaded when importing this module + # FormatsToProcess = @() } - - - From 230a31dfbfcae3eb5a2e909bfe1a3bfcaf3fec1a Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:06 +0000 Subject: [PATCH 18/50] Delete Public/Get-GitHubInstaller.ps1 --- Public/Get-GitHubInstaller.ps1 | 114 --------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 Public/Get-GitHubInstaller.ps1 diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 deleted file mode 100644 index d35a27f..0000000 --- a/Public/Get-GitHubInstaller.ps1 +++ /dev/null @@ -1,114 +0,0 @@ -function Get-GitHubInstaller { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$Id, - [string]$DownloadPath = "$env:TEMP\AppPackager" - ) - - Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" - - try { - # 1. Detect Architecture - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } - elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } - else { $SysArch = "x86" } - Write-PackagerLog -Message "System Architecture: $SysArch" - - # 2. Construct API Path - $IdPath = $Id.Replace(".", "/") - $FirstChar = $Id.Substring(0,1).ToLower() - $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" - - # 3. Get Version - $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -ErrorAction Stop - $LatestVersionObj = $VersionsResponse | - Where-Object { $_.type -eq "dir" } | - Select-Object *, @{N='ParsedVersion'; E={ try { [Version]$_.name } catch { $null } }} | - Where-Object { $_.ParsedVersion -ne $null } | - Sort-Object ParsedVersion -Descending | - Select-Object -First 1 - - if (-not $LatestVersionObj) { throw "Could not determine a valid numeric version." } - $LatestVersion = $LatestVersionObj.Name - Write-PackagerLog -Message "Latest Version Found: $LatestVersion" - - # 4. Get Manifest - $VersionPath = "$BaseApi/$LatestVersion" - $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get - $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 - if (-not $InstallerFile) { throw "No installer YAML found." } - - Write-PackagerLog -Message "Parsing Manifest: $($InstallerFile.name)" - $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url - - # --- PARSING LOGIC (Fixed Regex) --- - $Blocks = $YamlContent -split 'Architecture:\s*' - - $SelectedUrl = $null - $SelectedArgs = $null - $SelectedType = "exe" - - # Search Blocks for Match - foreach ($Block in $Blocks) { - if ([string]::IsNullOrWhiteSpace($Block)) { continue } - $BlockArch = $Block.Split("`r`n")[0].Trim() - - if ($BlockArch -eq $SysArch) { - # We found our block! - if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } - if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } - - # FIX: Greedy Regex to capture arguments with quotes (like /v"/qn") - if ($Block -match 'Silent:\s*(.+)') { - # Remove leading/trailing quotes from the YAML value itself, but keep internal quotes - $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') - } - elseif ($Block -match 'SilentWithProgress:\s*(.+)') { - $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') - } - break - } - } - - # Fallback: Scan Whole File if missing - if (-not $SelectedUrl) { - if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } - } - - if (-not $SelectedArgs) { - # FIX: Greedy Global Regex - if ($YamlContent -match 'Silent:\s*(.+)') { - $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') - Write-PackagerLog -Message "Found Global Arguments." - } - elseif ($YamlContent -match 'SilentWithProgress:\s*(.+)') { - $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') - } - } - - if (-not $SelectedUrl) { throw "Could not find an installer URL." } - - # --- DOWNLOAD --- - $UriObj = [System.Uri]$SelectedUrl - $RealExtension = [System.IO.Path]::GetExtension($UriObj.LocalPath).ToLower() - if (-not $RealExtension) { $RealExtension = ".$SelectedType" } - $FileName = "$Id-$LatestVersion-$SysArch$RealExtension" - - if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null - $FullPath = Join-Path $DownloadPath $FileName - - Write-PackagerLog -Message "Downloading to $FullPath..." - $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - Invoke-WebRequest -Uri $SelectedUrl -OutFile $FullPath -UseBasicParsing -UserAgent $UserAgent - - return [PSCustomObject]@{ - Name = $Id; InstallerPath = $FullPath; FileName = $FileName; SilentArgs = $SelectedArgs; InstallerType = $SelectedType - } - } - catch { - Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error - throw $_ - } -} \ No newline at end of file From f2b0eb3a7f84caae0f6fbd52a69efdacb9855bdb Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:15 +0000 Subject: [PATCH 19/50] Delete Public/Install-AppPackage.ps1 --- Public/Install-AppPackage.ps1 | 103 ---------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 Public/Install-AppPackage.ps1 diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 deleted file mode 100644 index 8fbf4c8..0000000 --- a/Public/Install-AppPackage.ps1 +++ /dev/null @@ -1,103 +0,0 @@ -function Install-AppPackage { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)][string]$FilePath, - [Parameter(Mandatory=$false)][object]$Arguments = @(), - [string]$Name = "Unknown Application" - ) - - $Extension = [System.IO.Path]::GetExtension($FilePath).ToLower() - if (Test-Path $FilePath) { Unblock-File -Path $FilePath } - - Write-PackagerLog -Message "Starting installation flow for: $Name ($Extension)" - - # --- ROUTING LOGIC --- - switch ($Extension) { - ".zip" { - Write-PackagerLog -Message "Detected ZIP. Extracting..." - $ZipName = [System.IO.Path]::GetFileNameWithoutExtension($FilePath) - $DestPath = Join-Path (Split-Path $FilePath) "Extracted_$ZipName" - if (Test-Path $DestPath) { Remove-Item $DestPath -Recurse -Force } - Expand-Archive -Path $FilePath -DestinationPath $DestPath -Force - - $Candidates = Get-ChildItem -Path $DestPath -Include *.exe,*.msi -Recurse - $Installer = $Candidates | Where-Object { $_.Name -match "setup" -or $_.Name -match "install" } | Select-Object -First 1 - if (-not $Installer) { $Installer = $Candidates | Sort-Object Length -Descending | Select-Object -First 1 } - if (-not $Installer) { throw "Extracted ZIP but could not find installer." } - - Write-PackagerLog -Message "Found installer inside ZIP: $($Installer.Name)" - Install-AppPackage -Name $Name -FilePath $Installer.FullName -Arguments $Arguments - return - } - { $_ -in ".msix", ".appx", ".msixbundle", ".appxbundle" } { - Write-PackagerLog -Message "Detected Modern App. Sideloading..." - try { - $PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx" - if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } - New-ItemProperty -Path $PolicyPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - - $DevPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - if (-not (Test-Path $DevPath)) { New-Item -Path $DevPath -Force | Out-Null } - New-ItemProperty -Path $DevPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - - Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null - Write-PackagerLog -Message "MSIX Provisioned Successfully." - } - catch { - Write-PackagerLog -Message "Provisioning Failed ($($_)). Trying Per-User..." -Severity Warning - try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } - } - return - } - ".msi" { - Write-PackagerLog -Message "Detected MSI. Switching to msiexec." - $MsiPath = $FilePath - $FilePath = "msiexec.exe" - # Logic to ensure /i is prepended cleanly - if ($Arguments -is [string]) { $Arguments = "/i `"$MsiPath`" $Arguments" } - else { $Arguments = @("/i", $MsiPath) + $Arguments } - } - } - - # --- SAFE EXECUTION --- - # 1. Sanitize Arguments (The Fix for the Null Crash) - if ($null -eq $Arguments) { $Arguments = @() } - - if ($Arguments -is [string]) { - # Regex split that respects quotes - $Regex = ' (?=(?:[^"]*"[^"]*")*[^"]*$)' - $ArgList = [regex]::Split($Arguments, $Regex) - } else { - $ArgList = $Arguments - } - - # Filter out nulls/empties from the array - $ArgList = $ArgList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - - Write-PackagerLog -Message "Executor: $FilePath" - Write-PackagerLog -Message "Final Args: $($ArgList -join ' | ')" - - try { - # If ArgList is empty, pass $null explicitly to avoid binding errors - if ($ArgList.Count -eq 0) { - $Process = Start-Process -FilePath $FilePath -PassThru -Wait -NoNewWindow - } else { - $Process = Start-Process -FilePath $FilePath -ArgumentList $ArgList -PassThru -Wait -NoNewWindow - } - - $ExitCode = $Process.ExitCode - Write-PackagerLog -Message "Finished. Exit Code: $ExitCode" - - switch ($ExitCode) { - 0 { Write-PackagerLog -Message "Success." } - 3010 { Write-PackagerLog -Message "Success (Reboot Required)." -Severity Warning } - 1641 { Write-PackagerLog -Message "Success (Hard Reboot Initiated)." -Severity Warning } - 4 { Write-PackagerLog -Message "Success (Reboot Required - Vendor Specific)." -Severity Warning } - default { throw "Failed with code $ExitCode" } - } - } - catch { - Write-PackagerLog -Message "Installation Failure: $_" -Severity Error - throw $_ - } -} \ No newline at end of file From 686b9b16636bc9583a2a01ce3567facea1181181 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:23 +0000 Subject: [PATCH 20/50] Delete Public/Install-NableAgent.ps1 --- Public/Install-NableAgent.ps1 | 80 ----------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 Public/Install-NableAgent.ps1 diff --git a/Public/Install-NableAgent.ps1 b/Public/Install-NableAgent.ps1 deleted file mode 100644 index 7ba9d31..0000000 --- a/Public/Install-NableAgent.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -function Install-NableAgent { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)][string]$CustomerID, - [Parameter(Mandatory=$true)][string]$Token, - [Parameter(Mandatory=$true)][string]$CustomerName, - [Parameter(Mandatory=$true)][string]$ServerAddress, - [int]$TimeoutSeconds = 1200 # 20 minutes - ) - - $Name = "N-able RMM Agent" - $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" - $DownloadPath = "$env:TEMP\AppPackager" - $FileName = "Nable_RMMInstaller.exe" - $InstallerPath = Join-Path $DownloadPath $FileName - - Write-PackagerLog -Message "Starting N-able Deployment for Customer: $CustomerName" - - try { - # 1. Download - if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null - - Write-PackagerLog -Message "Downloading installer from: $DownloadUrl" - - # We use a spoofed UserAgent just in case N-able blocks scripts, though usually not required here. - $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - Invoke-WebRequest -Uri $DownloadUrl -OutFile $InstallerPath -UseBasicParsing -UserAgent $UserAgent - - if (-not (Test-Path $InstallerPath)) { throw "Download failed. File not found." } - - # 2. Build Arguments (Standard InstallShield format) - # /S = Silent for the wrapper - # /v = Pass arguments to internal MSI - # We quote the MSI arguments carefully so Install-AppPackage regex keeps them together. - - $MsiArgs = "/qn CUSTOMERID=$CustomerID CUSTOMERNAME=$CustomerName CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$Token SERVERPROTOCOL=HTTPS SERVERADDRESS=$ServerAddress SERVERPORT=443" - - # NOTE: We construct the final string. Install-AppPackage's Regex will see "/v"..." " as one argument. - $FinalArgs = "/S /v`"$MsiArgs`"" - - # 3. Install (Using your module's executor) - Install-AppPackage -Name $Name -FilePath $InstallerPath -Arguments $FinalArgs - - # 4. Cleanup Installer immediately - Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue - - # 5. Validation Loop (The "Is it actually working?" check) - Write-PackagerLog -Message "Starting Post-Install Validation (Timeout: ${TimeoutSeconds}s)..." - - $StartTime = Get-Date - - while ($true) { - # Check Services and Files - $Service1 = Get-Service -Name "Windows Agent Service" -ErrorAction SilentlyContinue - $Service2 = Get-Service -Name "N-able Take Control Service (N-Central)" -ErrorAction SilentlyContinue - $FileCheck = Test-Path "C:\Program Files (x86)\BeAnywhere Support Express\GetSupportService_N-Central\uninstall.exe" - - if ($Service1 -and $Service2 -and $FileCheck) { - Write-PackagerLog -Message "Validation Successful: N-able services are running." - return # Success! - } - - # Check Timeout - $Elapsed = (Get-Date) - $StartTime - if ($Elapsed.TotalSeconds -ge $TimeoutSeconds) { - Write-PackagerLog -Message "Validation Timed Out. Services did not start in time." -Severity Error - throw "N-able installation finished, but validation failed (Timeout)." - } - - Write-PackagerLog -Message "Waiting for services to start..." - Start-Sleep -Seconds 10 - } - - } - catch { - Write-PackagerLog -Message "N-able Deployment Failed: $_" -Severity Error - throw $_ - } -} \ No newline at end of file From 182e62ed5cbee76a0113f8255c3800b5c6487c15 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:33 +0000 Subject: [PATCH 21/50] Delete Public/Install-TecharyApp.ps1 --- Public/Install-TecharyApp.ps1 | 70 ----------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 Public/Install-TecharyApp.ps1 diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 deleted file mode 100644 index 5b7362e..0000000 --- a/Public/Install-TecharyApp.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -$ProgressPreference = 'SilentlyContinue' - -function Install-TecharyApp { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)] - [string]$Id - ) - - $Pkg = $null - - # --- ATTEMPT 1: GITHUB --- - try { - $Pkg = Get-GitHubInstaller -Id $Id -ErrorAction Stop - } - catch { - Write-PackagerLog -Message "Not found in GitHub ($Id). Checking Custom Catalog..." -Severity Info - } - - # --- ATTEMPT 2: CUSTOM CATALOG --- - if (-not $Pkg) { - # Load the internal helper to check JSON - # (Assuming Get-CustomApp is dot-sourced in .psm1) - $CustomData = Get-CustomApp -Id $Id - - if ($CustomData) { - Write-PackagerLog -Message "Found '$Id' in Custom Catalog." - - # Use the Web Installer logic to download it - # We can reuse the logic or call Get-WebInstaller if you created it. - # Here is the inline logic for simplicity: - - $DownloadPath = "$env:TEMP\AppPackager" - if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null - - $FileName = "$Id.$($CustomData.InstallerType)" - $FullPath = Join-Path $DownloadPath $FileName - - Write-PackagerLog -Message "Downloading Custom App from: $($CustomData.Url)" - Invoke-WebRequest -Uri $CustomData.Url -OutFile $FullPath -UseBasicParsing - - # Build the Package Object manually - $Pkg = [PSCustomObject]@{ - Name = $Id - InstallerPath = $FullPath - FileName = $FileName - SilentArgs = $CustomData.SilentArgs - InstallerType = $CustomData.InstallerType - } - } - } - - if (-not $Pkg) { - Write-PackagerLog -Message "Application '$Id' not found in GitHub OR Custom Catalog." -Severity Error - return - } - - # --- INSTALLATION --- - # MSI Fallback Logic - $Args = $Pkg.SilentArgs - if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { - $Args = "ALLUSERS=1 /quiet /norestart" - } - - Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args - Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force - -} - From 9958f1a431102fd0467206054a18c2074e374ae1 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:41 +0000 Subject: [PATCH 22/50] Delete Public/Invoke-PackagerCleanup.ps1 --- Public/Invoke-PackagerCleanup.ps1 | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 Public/Invoke-PackagerCleanup.ps1 diff --git a/Public/Invoke-PackagerCleanup.ps1 b/Public/Invoke-PackagerCleanup.ps1 deleted file mode 100644 index 7b27a39..0000000 --- a/Public/Invoke-PackagerCleanup.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -function Invoke-PackagerCleanup { - [CmdletBinding()] - param ([string[]]$Paths = @("$env:TEMP\AppPackager"), [switch]$Force) - process { - foreach ($Path in $Paths) { - if (Test-Path $Path) { - Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue - Write-PackagerLog -Message "Cleaned: $Path" - } - } - } -} From 28b91e2d95f37f045c03d3422476f20daedde305 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:51 +0000 Subject: [PATCH 23/50] Delete Public/New-IntunePackage.ps1 --- Public/New-IntunePackage.ps1 | 70 ------------------------------------ 1 file changed, 70 deletions(-) delete mode 100644 Public/New-IntunePackage.ps1 diff --git a/Public/New-IntunePackage.ps1 b/Public/New-IntunePackage.ps1 deleted file mode 100644 index 32df464..0000000 --- a/Public/New-IntunePackage.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function New-IntunePackage { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$Id, # e.g. "Dell.CommandUpdate" - - [string]$OutputFolder = "C:\IntunePackages" - ) - - # --- 1. SETUP --- - $PackagerTemp = "$env:TEMP\IntunePackager_Working\$Id" - $SourceDir = "$PackagerTemp\Source" - $IntuneUtil = "$env:TEMP\IntuneWinAppUtil.exe" - - # Clean Workspace - if (Test-Path $PackagerTemp) { Remove-Item $PackagerTemp -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -Path $SourceDir -ItemType Directory -Force | Out-Null - - # Ensure Output Dir - if (-not (Test-Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } - - # Ensure Intune Utility - if (-not (Test-Path $IntuneUtil)) { - Write-PackagerLog -Message "Downloading IntuneWinAppUtil.exe..." - Invoke-WebRequest "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/raw/master/IntuneWinAppUtil.exe" -OutFile $IntuneUtil - } - - # --- 2. GENERATE INSTALL.PS1 (The One-Liner) --- - $InstallScript = @" -# Install Trigger for $Id -`$ErrorActionPreference = 'Stop' - -# Try to load the module if not already loaded (Assumes it is installed on the PC) -Import-Module TecharyGet -ErrorAction SilentlyContinue - -Write-Host "Triggering Install for: $Id" -Install-SmartApp -Id "$Id" -"@ - Set-Content -Path "$SourceDir\Install.ps1" -Value $InstallScript - - # --- 3. GENERATE UNINSTALL.PS1 (The One-Liner) --- - $UninstallScript = @" -# Uninstall Trigger for $Id -`$ErrorActionPreference = 'Stop' - -Import-Module TecharyGet -ErrorAction SilentlyContinue - -Write-Host "Triggering Uninstall for: $Id" -Uninstall-SmartApp -Name "$Id" -"@ - Set-Content -Path "$SourceDir\Uninstall.ps1" -Value $UninstallScript - - # --- 4. PACKAGE IT --- - Write-PackagerLog -Message "Packaging scripts into .intunewin..." - - $Process = Start-Process -FilePath $IntuneUtil ` - -ArgumentList "-c `"$SourceDir`"", "-s `"Install.ps1`"", "-o `"$OutputFolder`"", "-q" ` - -PassThru -Wait -NoNewWindow - - if ($Process.ExitCode -eq 0) { - $Original = Join-Path $OutputFolder "Install.intunewin" - $Final = Join-Path $OutputFolder "$Id.intunewin" - if (Test-Path $Original) { Move-Item $Original $Final -Force } - - Write-PackagerLog -Message "SUCCESS: Package created at $Final" - Invoke-Item $OutputFolder - } else { - Write-PackagerLog -Message "Packaging Failed with Exit Code $($Process.ExitCode)" -Severity Error - } -} \ No newline at end of file From c1c62bf6c83e8f66a199e93ee73ee14e17e3bb1d Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:57 +0000 Subject: [PATCH 24/50] Delete Public/New-IntunePackageUI.ps1 --- Public/New-IntunePackageUI.ps1 | 93 ---------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 Public/New-IntunePackageUI.ps1 diff --git a/Public/New-IntunePackageUI.ps1 b/Public/New-IntunePackageUI.ps1 deleted file mode 100644 index 46549f3..0000000 --- a/Public/New-IntunePackageUI.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -function New-IntunePackageUI { - [CmdletBinding()] - param() - - # Load Windows Forms - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - - # --- UI SETUP --- - $Form = New-Object System.Windows.Forms.Form - $Form.Text = "Techary Intune Packager (Lite)" - $Form.Size = New-Object System.Drawing.Size(500, 300) - $Form.StartPosition = "CenterScreen" - $Form.FormBorderStyle = "FixedDialog" - $Form.MaximizeBox = $false - - # -- App ID Field -- - $LblId = New-Object System.Windows.Forms.Label - $LblId.Text = "Application ID (e.g. 7zip.7zip):" - $LblId.Location = New-Object System.Drawing.Point(20, 20) - $LblId.Size = New-Object System.Drawing.Size(400, 20) - $Form.Controls.Add($LblId) - - $TxtId = New-Object System.Windows.Forms.TextBox - $TxtId.Location = New-Object System.Drawing.Point(20, 45) - $TxtId.Size = New-Object System.Drawing.Size(440, 25) - $Form.Controls.Add($TxtId) - - # -- Output Folder Field -- - $LblOut = New-Object System.Windows.Forms.Label - $LblOut.Text = "Output Folder:" - $LblOut.Location = New-Object System.Drawing.Point(20, 90) - $LblOut.Size = New-Object System.Drawing.Size(400, 20) - $Form.Controls.Add($LblOut) - - $TxtOut = New-Object System.Windows.Forms.TextBox - $TxtOut.Text = "C:\IntunePackages" # Default - $TxtOut.Location = New-Object System.Drawing.Point(20, 115) - $TxtOut.Size = New-Object System.Drawing.Size(350, 25) - $Form.Controls.Add($TxtOut) - - $BtnBrowse = New-Object System.Windows.Forms.Button - $BtnBrowse.Text = "..." - $BtnBrowse.Location = New-Object System.Drawing.Point(380, 114) - $BtnBrowse.Size = New-Object System.Drawing.Size(80, 27) - $BtnBrowse.Add_Click({ - $Dialog = New-Object System.Windows.Forms.FolderBrowserDialog - if ($Dialog.ShowDialog() -eq "OK") { $TxtOut.Text = $Dialog.SelectedPath } - }) - $Form.Controls.Add($BtnBrowse) - - # -- Create Button -- - $BtnRun = New-Object System.Windows.Forms.Button - $BtnRun.Text = "CREATE PACKAGE" - $BtnRun.Location = New-Object System.Drawing.Point(20, 170) - $BtnRun.Size = New-Object System.Drawing.Size(440, 50) - $BtnRun.BackColor = [System.Drawing.Color]::CornflowerBlue - $BtnRun.ForeColor = [System.Drawing.Color]::White - $BtnRun.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) - - $BtnRun.Add_Click({ - $Id = $TxtId.Text - $Out = $TxtOut.Text - - if (-not $Id) { - [System.Windows.Forms.MessageBox]::Show("Please enter an App ID.", "Error", "OK", "Warning") - return - } - - $BtnRun.Text = "Packaging..." - $BtnRun.Enabled = $false - $Form.Update() - - try { - # Call the backend function we created earlier - New-IntunePackage -Id $Id -OutputFolder $Out - - [System.Windows.Forms.MessageBox]::Show("Package Created Successfully!", "Success", "OK", "Information") - } - catch { - [System.Windows.Forms.MessageBox]::Show("Error: $_", "Failed", "OK", "Error") - } - finally { - $BtnRun.Text = "CREATE PACKAGE" - $BtnRun.Enabled = $true - } - }) - $Form.Controls.Add($BtnRun) - - # Show - $Form.ShowDialog() | Out-Null - $Form.Dispose() -} \ No newline at end of file From 292bad8054492f5b935778770251588993e1ad15 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:54:05 +0000 Subject: [PATCH 25/50] Delete Public/Uninstall-TecharyApp.ps1 --- Public/Uninstall-TecharyApp.ps1 | 112 -------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 Public/Uninstall-TecharyApp.ps1 diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 deleted file mode 100644 index 20d0c70..0000000 --- a/Public/Uninstall-TecharyApp.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -function Uninstall-TecharyApp { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$Name, - - [switch]$WhatIf - ) - - Write-PackagerLog -Message "Searching for installed application: $Name" - - # 1. SEARCH REGISTRY (Classic Apps) - $Paths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $App = $null - foreach ($Path in $Paths) { - $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "*$Name*" } | - Select-Object -First 1 - if ($App) { break } - } - - # 2. IF NOT IN REGISTRY, CHECK MSIX (Modern Apps) - if (-not $App) { - Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." - $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue - - if ($MsixResults) { - # FIX: Handle cases where multiple apps match (Array vs Single Object) - foreach ($Package in $MsixResults) { - Write-PackagerLog -Message "Found Modern App: $($Package.Name)" - - if ($WhatIf) { - Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow - continue - } - - try { - Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name)" - } - catch { - Write-PackagerLog -Message "Failed to remove $($Package.Name): $_" -Severity Error - } - } - return - } - - Write-PackagerLog -Message "Application '$Name' not found on this system." -Severity Warning - return - } - - # 3. DETERMINE UNINSTALL COMMAND (Classic Apps) - $UninstallString = $null - $Type = "EXE" - - if ($App.UninstallString -match "MsiExec.exe") { - $Type = "MSI" - if ($App.UninstallString -match '{[A-F0-9-]+}') { - $Guid = $Matches[0] - $UninstallString = "msiexec.exe" - $Arguments = "/x $Guid /qn /norestart" - } - } - else { - # EXE Uninstaller logic - if ($App.QuietUninstallString) { - $RawString = $App.QuietUninstallString - } else { - $RawString = $App.UninstallString - } - - if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { - $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } - $ArgsPart = $Matches[3].Trim() - - $UninstallString = $Exe - $Arguments = $ArgsPart - - if (-not ($Arguments -match "/S|/silent|/qn|/quiet")) { - $Arguments = "$Arguments /S /silent /quiet /norestart" - } - } - } - - Write-PackagerLog -Message "Found: $($App.DisplayName) ($Type)" - Write-PackagerLog -Message "Command: $UninstallString $Arguments" - - if ($WhatIf) { - Write-Host "[WhatIf] Would execute: $UninstallString $Arguments" -ForegroundColor Yellow - return - } - - # 4. EXECUTE REMOVAL - try { - $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow - - if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { - Write-PackagerLog -Message "Uninstallation Successful." - } else { - Write-PackagerLog -Message "Uninstallation finished with Exit Code: $($Process.ExitCode)" -Severity Warning - } - } - catch { - Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error - } - -} From d6f4a83b20eb6713299f6012382511cb4e0771dc Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:54:11 +0000 Subject: [PATCH 26/50] Delete Public/Write-PackagerLog.ps1 --- Public/Write-PackagerLog.ps1 | 37 ------------------------------------ 1 file changed, 37 deletions(-) delete mode 100644 Public/Write-PackagerLog.ps1 diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 deleted file mode 100644 index ed2d4ee..0000000 --- a/Public/Write-PackagerLog.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -function Write-PackagerLog { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)][string]$Message, - [ValidateSet("Info", "Warning", "Error")][string]$Severity = "Info" - ) - - $LogPath = "$env:ProgramData\TecharyGet\InstallLogs.log" - $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $Line = "[$Timestamp] [$Severity] $Message" - - # 1. Console Output - $Color = switch ($Severity) { "Info" {"Green"} "Warning" {"Yellow"} "Error" {"Red"} } - Write-Host $Line -ForegroundColor $Color - - # 2. File Log - if (-not (Test-Path (Split-Path $LogPath))) { New-Item -ItemType Directory (Split-Path $LogPath) -Force | Out-Null } - Add-Content -Path $LogPath -Value $Line - - # 3. ENTERPRISE EVENT LOGGING (New!) - # N-able can pick this up easily. - # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) - - $EventSource = "TecharyGet" - if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { - # Requires Admin to create source once. - # If not admin, this skips silently to avoid crashing. - try { New-EventLog -LogName Application -Source $EventSource -ErrorAction SilentlyContinue } catch {} - } - - if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { - $EventID = switch ($Severity) { "Info" {100} "Warning" {200} "Error" {300} } - $EntryType = switch ($Severity) { "Info" {"Information"} "Warning" {"Warning"} "Error" {"Error"} } - - Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message - } -} \ No newline at end of file From ddf7ffeb2b1946cb198951b679234b7d8213aeaf Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:54:50 +0000 Subject: [PATCH 27/50] Add aa.txt with initial content 'aa' --- Public/aa.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 Public/aa.txt diff --git a/Public/aa.txt b/Public/aa.txt new file mode 100644 index 0000000..e61ef7b --- /dev/null +++ b/Public/aa.txt @@ -0,0 +1 @@ +aa From 533b85fe1f85d9a2993f41234fec38497de1a818 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:55:31 +0000 Subject: [PATCH 28/50] Add files via upload --- Public/Get-GitHubInstaller.ps1 | 117 +++++++++++++++++++++ Public/Install-AppPackage.ps1 | 103 +++++++++++++++++++ Public/Install-NableAgent.ps1 | 80 +++++++++++++++ Public/Install-TecharyApp.ps1 | 68 ++++++++++++ Public/Invoke-PackagerCleanup.ps1 | 12 +++ Public/New-IntunePackage.ps1 | 165 ++++++++++++++++++++++++++++++ Public/New-IntunePackageUI.ps1 | 93 +++++++++++++++++ Public/Test-TecharyApp.ps1 | 42 ++++++++ Public/Uninstall-TecharyApp.ps1 | 111 ++++++++++++++++++++ Public/Write-PackagerLog.ps1 | 37 +++++++ 10 files changed, 828 insertions(+) create mode 100644 Public/Get-GitHubInstaller.ps1 create mode 100644 Public/Install-AppPackage.ps1 create mode 100644 Public/Install-NableAgent.ps1 create mode 100644 Public/Install-TecharyApp.ps1 create mode 100644 Public/Invoke-PackagerCleanup.ps1 create mode 100644 Public/New-IntunePackage.ps1 create mode 100644 Public/New-IntunePackageUI.ps1 create mode 100644 Public/Test-TecharyApp.ps1 create mode 100644 Public/Uninstall-TecharyApp.ps1 create mode 100644 Public/Write-PackagerLog.ps1 diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 new file mode 100644 index 0000000..747828f --- /dev/null +++ b/Public/Get-GitHubInstaller.ps1 @@ -0,0 +1,117 @@ +function Get-GitHubInstaller { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Id, + [string]$DownloadPath = "$env:TEMP\AppPackager" + ) + + Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" + + try { + # 1. Detect Architecture + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # 2. Construct API Path + $IdPath = $Id.Replace(".", "/") + $FirstChar = $Id.Substring(0,1).ToLower() + $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" + + # 3. Get Version (Latest) + $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -ErrorAction Stop + $LatestVersionObj = $VersionsResponse | + Where-Object { $_.type -eq "dir" } | + Select-Object *, @{N='ParsedVersion'; E={ try { [Version]$_.name } catch { $null } }} | + Where-Object { $_.ParsedVersion -ne $null } | + Sort-Object ParsedVersion -Descending | + Select-Object -First 1 + + if (-not $LatestVersionObj) { throw "Could not determine a valid numeric version." } + $LatestVersion = $LatestVersionObj.Name + + # 4. Get Manifest + $VersionPath = "$BaseApi/$LatestVersion" + $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get + $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 + if (-not $InstallerFile) { throw "No installer YAML found." } + + $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url + + # --- PARSING LOGIC --- + # We split by "- Architecture" to separate blocks, but keep the delimiter to help identification + $Blocks = $YamlContent -split '(?=-\s*Architecture:)' + + $SelectedUrl = $null + $SelectedArgs = $null + $SelectedType = "exe" + $SelectedCode = $null + + foreach ($Block in $Blocks) { + if ([string]::IsNullOrWhiteSpace($Block)) { continue } + + # Extract Architecture from this block + if ($Block -match 'Architecture:\s*([a-zA-Z0-9]+)') { + $BlockArch = $Matches[1].Trim() + + # If this block matches our system, scrape it! + if ($BlockArch -eq $SysArch) { + if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } + if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } + + # Scrape Arguments + if ($Block -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + elseif ($Block -match 'SilentWithProgress:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + + # Scrape Product Code (Flexible Regex) + # This now matches "{GUID}" OR "SimpleString" + if ($Block -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { + $SelectedCode = $Matches[1].Trim() + } + + # If we found a URL, we stop looking (we prefer the first match for our arch) + if ($SelectedUrl) { break } + } + } + } + + # Fallbacks (Global properties if not in block) + if (-not $SelectedUrl) { if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } } + if (-not $SelectedArgs) { + if ($YamlContent -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + } + if (-not $SelectedCode) { + if ($YamlContent -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + } + + # Special Override for Dell (Command Update) + if ($Id -eq "Dell.CommandUpdate") { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + + # --- DOWNLOAD --- + $UriObj = [System.Uri]$SelectedUrl + $RealExtension = [System.IO.Path]::GetExtension($UriObj.LocalPath).ToLower() + if (-not $RealExtension) { $RealExtension = ".$SelectedType" } + $FileName = "$Id-$LatestVersion-$SysArch$RealExtension" + + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + $FullPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Downloading to $FullPath..." + Invoke-WebRequest -Uri $SelectedUrl -OutFile $FullPath -UseBasicParsing -UserAgent "Mozilla/5.0" + + return [PSCustomObject]@{ + Name = $Id + InstallerPath = $FullPath + FileName = $FileName + SilentArgs = $SelectedArgs + InstallerType = $SelectedType + ProductCode = $SelectedCode + } + } + catch { + Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 new file mode 100644 index 0000000..8fbf4c8 --- /dev/null +++ b/Public/Install-AppPackage.ps1 @@ -0,0 +1,103 @@ +function Install-AppPackage { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$FilePath, + [Parameter(Mandatory=$false)][object]$Arguments = @(), + [string]$Name = "Unknown Application" + ) + + $Extension = [System.IO.Path]::GetExtension($FilePath).ToLower() + if (Test-Path $FilePath) { Unblock-File -Path $FilePath } + + Write-PackagerLog -Message "Starting installation flow for: $Name ($Extension)" + + # --- ROUTING LOGIC --- + switch ($Extension) { + ".zip" { + Write-PackagerLog -Message "Detected ZIP. Extracting..." + $ZipName = [System.IO.Path]::GetFileNameWithoutExtension($FilePath) + $DestPath = Join-Path (Split-Path $FilePath) "Extracted_$ZipName" + if (Test-Path $DestPath) { Remove-Item $DestPath -Recurse -Force } + Expand-Archive -Path $FilePath -DestinationPath $DestPath -Force + + $Candidates = Get-ChildItem -Path $DestPath -Include *.exe,*.msi -Recurse + $Installer = $Candidates | Where-Object { $_.Name -match "setup" -or $_.Name -match "install" } | Select-Object -First 1 + if (-not $Installer) { $Installer = $Candidates | Sort-Object Length -Descending | Select-Object -First 1 } + if (-not $Installer) { throw "Extracted ZIP but could not find installer." } + + Write-PackagerLog -Message "Found installer inside ZIP: $($Installer.Name)" + Install-AppPackage -Name $Name -FilePath $Installer.FullName -Arguments $Arguments + return + } + { $_ -in ".msix", ".appx", ".msixbundle", ".appxbundle" } { + Write-PackagerLog -Message "Detected Modern App. Sideloading..." + try { + $PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx" + if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } + New-ItemProperty -Path $PolicyPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null + + $DevPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + if (-not (Test-Path $DevPath)) { New-Item -Path $DevPath -Force | Out-Null } + New-ItemProperty -Path $DevPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null + + Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "MSIX Provisioned Successfully." + } + catch { + Write-PackagerLog -Message "Provisioning Failed ($($_)). Trying Per-User..." -Severity Warning + try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + } + return + } + ".msi" { + Write-PackagerLog -Message "Detected MSI. Switching to msiexec." + $MsiPath = $FilePath + $FilePath = "msiexec.exe" + # Logic to ensure /i is prepended cleanly + if ($Arguments -is [string]) { $Arguments = "/i `"$MsiPath`" $Arguments" } + else { $Arguments = @("/i", $MsiPath) + $Arguments } + } + } + + # --- SAFE EXECUTION --- + # 1. Sanitize Arguments (The Fix for the Null Crash) + if ($null -eq $Arguments) { $Arguments = @() } + + if ($Arguments -is [string]) { + # Regex split that respects quotes + $Regex = ' (?=(?:[^"]*"[^"]*")*[^"]*$)' + $ArgList = [regex]::Split($Arguments, $Regex) + } else { + $ArgList = $Arguments + } + + # Filter out nulls/empties from the array + $ArgList = $ArgList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + Write-PackagerLog -Message "Executor: $FilePath" + Write-PackagerLog -Message "Final Args: $($ArgList -join ' | ')" + + try { + # If ArgList is empty, pass $null explicitly to avoid binding errors + if ($ArgList.Count -eq 0) { + $Process = Start-Process -FilePath $FilePath -PassThru -Wait -NoNewWindow + } else { + $Process = Start-Process -FilePath $FilePath -ArgumentList $ArgList -PassThru -Wait -NoNewWindow + } + + $ExitCode = $Process.ExitCode + Write-PackagerLog -Message "Finished. Exit Code: $ExitCode" + + switch ($ExitCode) { + 0 { Write-PackagerLog -Message "Success." } + 3010 { Write-PackagerLog -Message "Success (Reboot Required)." -Severity Warning } + 1641 { Write-PackagerLog -Message "Success (Hard Reboot Initiated)." -Severity Warning } + 4 { Write-PackagerLog -Message "Success (Reboot Required - Vendor Specific)." -Severity Warning } + default { throw "Failed with code $ExitCode" } + } + } + catch { + Write-PackagerLog -Message "Installation Failure: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-NableAgent.ps1 b/Public/Install-NableAgent.ps1 new file mode 100644 index 0000000..7ba9d31 --- /dev/null +++ b/Public/Install-NableAgent.ps1 @@ -0,0 +1,80 @@ +function Install-NableAgent { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$CustomerID, + [Parameter(Mandatory=$true)][string]$Token, + [Parameter(Mandatory=$true)][string]$CustomerName, + [Parameter(Mandatory=$true)][string]$ServerAddress, + [int]$TimeoutSeconds = 1200 # 20 minutes + ) + + $Name = "N-able RMM Agent" + $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" + $DownloadPath = "$env:TEMP\AppPackager" + $FileName = "Nable_RMMInstaller.exe" + $InstallerPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Starting N-able Deployment for Customer: $CustomerName" + + try { + # 1. Download + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + + Write-PackagerLog -Message "Downloading installer from: $DownloadUrl" + + # We use a spoofed UserAgent just in case N-able blocks scripts, though usually not required here. + $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + Invoke-WebRequest -Uri $DownloadUrl -OutFile $InstallerPath -UseBasicParsing -UserAgent $UserAgent + + if (-not (Test-Path $InstallerPath)) { throw "Download failed. File not found." } + + # 2. Build Arguments (Standard InstallShield format) + # /S = Silent for the wrapper + # /v = Pass arguments to internal MSI + # We quote the MSI arguments carefully so Install-AppPackage regex keeps them together. + + $MsiArgs = "/qn CUSTOMERID=$CustomerID CUSTOMERNAME=$CustomerName CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$Token SERVERPROTOCOL=HTTPS SERVERADDRESS=$ServerAddress SERVERPORT=443" + + # NOTE: We construct the final string. Install-AppPackage's Regex will see "/v"..." " as one argument. + $FinalArgs = "/S /v`"$MsiArgs`"" + + # 3. Install (Using your module's executor) + Install-AppPackage -Name $Name -FilePath $InstallerPath -Arguments $FinalArgs + + # 4. Cleanup Installer immediately + Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue + + # 5. Validation Loop (The "Is it actually working?" check) + Write-PackagerLog -Message "Starting Post-Install Validation (Timeout: ${TimeoutSeconds}s)..." + + $StartTime = Get-Date + + while ($true) { + # Check Services and Files + $Service1 = Get-Service -Name "Windows Agent Service" -ErrorAction SilentlyContinue + $Service2 = Get-Service -Name "N-able Take Control Service (N-Central)" -ErrorAction SilentlyContinue + $FileCheck = Test-Path "C:\Program Files (x86)\BeAnywhere Support Express\GetSupportService_N-Central\uninstall.exe" + + if ($Service1 -and $Service2 -and $FileCheck) { + Write-PackagerLog -Message "Validation Successful: N-able services are running." + return # Success! + } + + # Check Timeout + $Elapsed = (Get-Date) - $StartTime + if ($Elapsed.TotalSeconds -ge $TimeoutSeconds) { + Write-PackagerLog -Message "Validation Timed Out. Services did not start in time." -Severity Error + throw "N-able installation finished, but validation failed (Timeout)." + } + + Write-PackagerLog -Message "Waiting for services to start..." + Start-Sleep -Seconds 10 + } + + } + catch { + Write-PackagerLog -Message "N-able Deployment Failed: $_" -Severity Error + throw $_ + } +} \ No newline at end of file diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 new file mode 100644 index 0000000..d54e73a --- /dev/null +++ b/Public/Install-TecharyApp.ps1 @@ -0,0 +1,68 @@ +$ProgressPreference = 'SilentlyContinue' + +function Install-TecharyApp { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Id + ) + + $Pkg = $null + + # --- ATTEMPT 1: GITHUB --- + try { + $Pkg = Get-GitHubInstaller -Id $Id -ErrorAction Stop + } + catch { + Write-PackagerLog -Message "Not found in GitHub ($Id). Checking Custom Catalog..." -Severity Info + } + + # --- ATTEMPT 2: CUSTOM CATALOG --- + if (-not $Pkg) { + # Load the internal helper to check JSON + # (Assuming Get-CustomApp is dot-sourced in .psm1) + $CustomData = Get-CustomApp -Id $Id + + if ($CustomData) { + Write-PackagerLog -Message "Found '$Id' in Custom Catalog." + + # Use the Web Installer logic to download it + # We can reuse the logic or call Get-WebInstaller if you created it. + # Here is the inline logic for simplicity: + + $DownloadPath = "$env:TEMP\AppPackager" + if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + + $FileName = "$Id.$($CustomData.InstallerType)" + $FullPath = Join-Path $DownloadPath $FileName + + Write-PackagerLog -Message "Downloading Custom App from: $($CustomData.Url)" + Invoke-WebRequest -Uri $CustomData.Url -OutFile $FullPath -UseBasicParsing + + # Build the Package Object manually + $Pkg = [PSCustomObject]@{ + Name = $Id + InstallerPath = $FullPath + FileName = $FileName + SilentArgs = $CustomData.SilentArgs + InstallerType = $CustomData.InstallerType + } + } + } + + if (-not $Pkg) { + Write-PackagerLog -Message "Application '$Id' not found in GitHub OR Custom Catalog." -Severity Error + return + } + + # --- INSTALLATION --- + # MSI Fallback Logic + $Args = $Pkg.SilentArgs + if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { + $Args = "/qb /norestart" + } + + Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args + Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force +} \ No newline at end of file diff --git a/Public/Invoke-PackagerCleanup.ps1 b/Public/Invoke-PackagerCleanup.ps1 new file mode 100644 index 0000000..7b27a39 --- /dev/null +++ b/Public/Invoke-PackagerCleanup.ps1 @@ -0,0 +1,12 @@ +function Invoke-PackagerCleanup { + [CmdletBinding()] + param ([string[]]$Paths = @("$env:TEMP\AppPackager"), [switch]$Force) + process { + foreach ($Path in $Paths) { + if (Test-Path $Path) { + Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue + Write-PackagerLog -Message "Cleaned: $Path" + } + } + } +} diff --git a/Public/New-IntunePackage.ps1 b/Public/New-IntunePackage.ps1 new file mode 100644 index 0000000..caee5c8 --- /dev/null +++ b/Public/New-IntunePackage.ps1 @@ -0,0 +1,165 @@ +function New-IntunePackage { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Id, # e.g. "Dell.CommandUpdate" or "Nable" + + [string]$OutputFolder = "C:\IntunePackages" + ) + + # --- 1. SETUP WORKSPACE --- + $PackagerTemp = "$env:TEMP\IntunePackager_Working\$Id" + $SourceDir = "$PackagerTemp\Source" + $IntuneUtil = "$env:TEMP\IntuneWinAppUtil.exe" + + if (Test-Path $PackagerTemp) { Remove-Item $PackagerTemp -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -Path $SourceDir -ItemType Directory -Force | Out-Null + if (-not (Test-Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } + + if (-not (Test-Path $IntuneUtil)) { + Write-PackagerLog -Message "Downloading IntuneWinAppUtil..." + Invoke-WebRequest "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/raw/master/IntuneWinAppUtil.exe" -OutFile $IntuneUtil + } + + # ========================================== + # SPECIAL LOGIC: N-ABLE AGENT + # ========================================== + if ($Id -eq "Nable") { + # 1. Prompt User for N-able Details (GUI) + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + $Form = New-Object System.Windows.Forms.Form + $Form.Text = "N-able Configuration" + $Form.Size = New-Object System.Drawing.Size(400, 350) + $Form.StartPosition = "CenterScreen" + + $Inputs = @{} + $Top = 20 + foreach ($Field in @("CustomerName", "CustomerID", "Token", "ServerAddress")) { + $Lbl = New-Object System.Windows.Forms.Label + $Lbl.Text = $Field + $Lbl.Location = New-Object System.Drawing.Point(20, $Top) + $Form.Controls.Add($Lbl) + + $Box = New-Object System.Windows.Forms.TextBox + $Box.Location = New-Object System.Drawing.Point(20, $Top + 25) + $Box.Size = New-Object System.Drawing.Size(340, 25) + $Form.Controls.Add($Box) + $Inputs[$Field] = $Box + $Top += 60 + } + + $Btn = New-Object System.Windows.Forms.Button + $Btn.Text = "Generate Package" + $Btn.Location = New-Object System.Drawing.Point(20, $Top) + $Btn.Size = New-Object System.Drawing.Size(340, 40) + $Btn.DialogResult = "OK" + $Form.Controls.Add($Btn) + + $Result = $Form.ShowDialog() + if ($Result -ne "OK") { throw "Cancelled by user." } + + # 2. Extract Values + $CName = $Inputs["CustomerName"].Text + $CId = $Inputs["CustomerID"].Text + $CToken= $Inputs["Token"].Text + $CServer=$Inputs["ServerAddress"].Text + + if (-not $CName -or -not $CId -or -not $CToken -or -not $CServer) { throw "All N-able fields are required." } + + # 3. Generate Install Script (With HARDCODED Values) + $InstallContent = @" +`$ErrorActionPreference = 'Stop' +Import-Module TecharyGet -ErrorAction SilentlyContinue + +Write-Host "Installing N-able Agent for $CName..." +Install-NableAgent -CustomerName "$CName" -CustomerID "$CId" -Token "$CToken" -ServerAddress "$CServer" +"@ + Set-Content -Path "$SourceDir\Install.ps1" -Value $InstallContent + + # 4. Generate Uninstall Script + Set-Content -Path "$SourceDir\Uninstall.ps1" -Value "`$ErrorActionPreference = 'Stop'; Import-Module TecharyGet; Uninstall-SmartApp -Name 'Windows Agent'" + + # 5. Generate Detection Script (Service Check) + $DetectContent = @" +`$Service = Get-Service -Name "Windows Agent Service" -ErrorAction SilentlyContinue +if (`$Service) { + Write-Output "Detected N-able Agent Service" + exit 0 +} else { + exit 1 +} +"@ + Set-Content -Path "$SourceDir\Detect.ps1" -Value $DetectContent + + # Override output name so you can have multiple packages (e.g. "Nable-ClientA.intunewin") + $PackageName = "Nable-$CName" + + } + # ========================================== + # STANDARD LOGIC: GENERIC APPS + # ========================================== + else { + # ... (Previous Logic for 7zip, Dell, etc.) ... + + # 1. Gather Info + $ProductCode = $null + $DisplayName = $Id + try { + if (Get-Command Get-CustomApp -ErrorAction SilentlyContinue) { + $Custom = Get-CustomApp -Id $Id + if ($Custom) { + if ($Custom.DisplayName) { $DisplayName = $Custom.DisplayName } + if ($Custom.ProductCode) { $ProductCode = $Custom.ProductCode } + } + } + if (-not $ProductCode) { + $Pkg = Get-GitHubInstaller -Id $Id -DownloadPath "$PackagerTemp\Probe" -ErrorAction SilentlyContinue + if ($Pkg.ProductCode) { $ProductCode = $Pkg.ProductCode } + } + } catch {} + + # 2. Generate Scripts + Set-Content -Path "$SourceDir\Install.ps1" -Value "`$ErrorActionPreference = 'Stop'; Import-Module TecharyGet -ErrorAction SilentlyContinue; Install-SmartApp -Id `"$Id`"" + Set-Content -Path "$SourceDir\Uninstall.ps1" -Value "`$ErrorActionPreference = 'Stop'; Import-Module TecharyGet -ErrorAction SilentlyContinue; Uninstall-SmartApp -Name `"$Id`"" + + # 3. Generate Detection + $DetectScript = @" +`$TargetCode = '$ProductCode' +`$TargetName = '$DisplayName' +`$Found = `$false + +if (-not [string]::IsNullOrEmpty(`$TargetCode)) { + if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\`$TargetCode") { `$Found = `$true } + elseif (Test-Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\`$TargetCode") { `$Found = `$true } + if (`$Found) { Write-Output "Detected via Key"; exit 0 } +} + +`$Paths = @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*") +foreach (`$Path in `$Paths) { + `$Match = Get-ItemProperty `$Path -ErrorAction SilentlyContinue | Where-Object { `$_.DisplayName -like "*`$TargetName*" } | Select-Object -First 1 + if (`$Match) { Write-Output "Detected via Name"; exit 0 } +} +exit 1 +"@ + Set-Content -Path "$SourceDir\Detect.ps1" -Value $DetectScript + + $PackageName = $Id + } + + # --- FINAL PACKAGING STEP --- + Write-PackagerLog -Message "Packaging $PackageName..." + Start-Process -FilePath $IntuneUtil -ArgumentList "-c `"$SourceDir`"", "-s `"Install.ps1`"", "-o `"$OutputFolder`"", "-q" -Wait -NoNewWindow + + # Rename Output + $Original = Join-Path $OutputFolder "Install.intunewin" + $Final = Join-Path $OutputFolder "$PackageName.intunewin" + if (Test-Path $Original) { Move-Item $Original $Final -Force } + + # Copy Detect Script + Copy-Item "$SourceDir\Detect.ps1" -Destination "$OutputFolder\$PackageName-Detect.ps1" -Force + + Write-PackagerLog -Message "SUCCESS: Created $Final" + Invoke-Item $OutputFolder +} \ No newline at end of file diff --git a/Public/New-IntunePackageUI.ps1 b/Public/New-IntunePackageUI.ps1 new file mode 100644 index 0000000..46549f3 --- /dev/null +++ b/Public/New-IntunePackageUI.ps1 @@ -0,0 +1,93 @@ +function New-IntunePackageUI { + [CmdletBinding()] + param() + + # Load Windows Forms + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + # --- UI SETUP --- + $Form = New-Object System.Windows.Forms.Form + $Form.Text = "Techary Intune Packager (Lite)" + $Form.Size = New-Object System.Drawing.Size(500, 300) + $Form.StartPosition = "CenterScreen" + $Form.FormBorderStyle = "FixedDialog" + $Form.MaximizeBox = $false + + # -- App ID Field -- + $LblId = New-Object System.Windows.Forms.Label + $LblId.Text = "Application ID (e.g. 7zip.7zip):" + $LblId.Location = New-Object System.Drawing.Point(20, 20) + $LblId.Size = New-Object System.Drawing.Size(400, 20) + $Form.Controls.Add($LblId) + + $TxtId = New-Object System.Windows.Forms.TextBox + $TxtId.Location = New-Object System.Drawing.Point(20, 45) + $TxtId.Size = New-Object System.Drawing.Size(440, 25) + $Form.Controls.Add($TxtId) + + # -- Output Folder Field -- + $LblOut = New-Object System.Windows.Forms.Label + $LblOut.Text = "Output Folder:" + $LblOut.Location = New-Object System.Drawing.Point(20, 90) + $LblOut.Size = New-Object System.Drawing.Size(400, 20) + $Form.Controls.Add($LblOut) + + $TxtOut = New-Object System.Windows.Forms.TextBox + $TxtOut.Text = "C:\IntunePackages" # Default + $TxtOut.Location = New-Object System.Drawing.Point(20, 115) + $TxtOut.Size = New-Object System.Drawing.Size(350, 25) + $Form.Controls.Add($TxtOut) + + $BtnBrowse = New-Object System.Windows.Forms.Button + $BtnBrowse.Text = "..." + $BtnBrowse.Location = New-Object System.Drawing.Point(380, 114) + $BtnBrowse.Size = New-Object System.Drawing.Size(80, 27) + $BtnBrowse.Add_Click({ + $Dialog = New-Object System.Windows.Forms.FolderBrowserDialog + if ($Dialog.ShowDialog() -eq "OK") { $TxtOut.Text = $Dialog.SelectedPath } + }) + $Form.Controls.Add($BtnBrowse) + + # -- Create Button -- + $BtnRun = New-Object System.Windows.Forms.Button + $BtnRun.Text = "CREATE PACKAGE" + $BtnRun.Location = New-Object System.Drawing.Point(20, 170) + $BtnRun.Size = New-Object System.Drawing.Size(440, 50) + $BtnRun.BackColor = [System.Drawing.Color]::CornflowerBlue + $BtnRun.ForeColor = [System.Drawing.Color]::White + $BtnRun.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) + + $BtnRun.Add_Click({ + $Id = $TxtId.Text + $Out = $TxtOut.Text + + if (-not $Id) { + [System.Windows.Forms.MessageBox]::Show("Please enter an App ID.", "Error", "OK", "Warning") + return + } + + $BtnRun.Text = "Packaging..." + $BtnRun.Enabled = $false + $Form.Update() + + try { + # Call the backend function we created earlier + New-IntunePackage -Id $Id -OutputFolder $Out + + [System.Windows.Forms.MessageBox]::Show("Package Created Successfully!", "Success", "OK", "Information") + } + catch { + [System.Windows.Forms.MessageBox]::Show("Error: $_", "Failed", "OK", "Error") + } + finally { + $BtnRun.Text = "CREATE PACKAGE" + $BtnRun.Enabled = $true + } + }) + $Form.Controls.Add($BtnRun) + + # Show + $Form.ShowDialog() | Out-Null + $Form.Dispose() +} \ No newline at end of file diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 new file mode 100644 index 0000000..82ebb9e --- /dev/null +++ b/Public/Test-TecharyApp.ps1 @@ -0,0 +1,42 @@ +function Test-TecharyApp { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Name # App ID (e.g. "MyDPD") or Display Name + ) + + # 1. RESOLVE ID -> DISPLAY NAME + # Check if this ID exists in your Custom Catalog with a specific DisplayName mapping + $CustomApp = Get-CustomApp -Id $Name + if ($CustomApp -and $CustomApp.DisplayName) { + $Name = $CustomApp.DisplayName + } + + # 2. SEARCH REGISTRY (Classic Apps) + $Paths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + foreach ($Path in $Paths) { + $Match = Get-ItemProperty $Path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "*$Name*" } | + Select-Object -First 1 + + if ($Match) { + Write-Verbose "Found Registry Match: $($Match.DisplayName)" + return $true + } + } + + # 3. SEARCH MSIX (Modern Apps) + $Msix = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($Msix) { + Write-Verbose "Found MSIX: $($Msix.Name)" + return $true + } + + # 4. NOT FOUND + return $false +} \ No newline at end of file diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 new file mode 100644 index 0000000..8b4fdd6 --- /dev/null +++ b/Public/Uninstall-TecharyApp.ps1 @@ -0,0 +1,111 @@ +function Uninstall-TecharyApp { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Name, + + [switch]$WhatIf + ) + + Write-PackagerLog -Message "Searching for installed application: $Name" + + # 1. SEARCH REGISTRY (Classic Apps) + $Paths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $App = $null + foreach ($Path in $Paths) { + $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "*$Name*" } | + Select-Object -First 1 + if ($App) { break } + } + + # 2. IF NOT IN REGISTRY, CHECK MSIX (Modern Apps) + if (-not $App) { + Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." + $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue + + if ($MsixResults) { + # FIX: Handle cases where multiple apps match (Array vs Single Object) + foreach ($Package in $MsixResults) { + Write-PackagerLog -Message "Found Modern App: $($Package.Name)" + + if ($WhatIf) { + Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow + continue + } + + try { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name)" + } + catch { + Write-PackagerLog -Message "Failed to remove $($Package.Name): $_" -Severity Error + } + } + return + } + + Write-PackagerLog -Message "Application '$Name' not found on this system." -Severity Warning + return + } + + # 3. DETERMINE UNINSTALL COMMAND (Classic Apps) + $UninstallString = $null + $Type = "EXE" + + if ($App.UninstallString -match "MsiExec.exe") { + $Type = "MSI" + if ($App.UninstallString -match '{[A-F0-9-]+}') { + $Guid = $Matches[0] + $UninstallString = "msiexec.exe" + $Arguments = "/x $Guid /qn /norestart" + } + } + else { + # EXE Uninstaller logic + if ($App.QuietUninstallString) { + $RawString = $App.QuietUninstallString + } else { + $RawString = $App.UninstallString + } + + if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { + $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + $ArgsPart = $Matches[3].Trim() + + $UninstallString = $Exe + $Arguments = $ArgsPart + + if (-not ($Arguments -match "/S|/silent|/qn|/quiet")) { + $Arguments = "$Arguments /S /silent /quiet /norestart" + } + } + } + + Write-PackagerLog -Message "Found: $($App.DisplayName) ($Type)" + Write-PackagerLog -Message "Command: $UninstallString $Arguments" + + if ($WhatIf) { + Write-Host "[WhatIf] Would execute: $UninstallString $Arguments" -ForegroundColor Yellow + return + } + + # 4. EXECUTE REMOVAL + try { + $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow + + if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { + Write-PackagerLog -Message "Uninstallation Successful." + } else { + Write-PackagerLog -Message "Uninstallation finished with Exit Code: $($Process.ExitCode)" -Severity Warning + } + } + catch { + Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error + } +} \ No newline at end of file diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 new file mode 100644 index 0000000..ed2d4ee --- /dev/null +++ b/Public/Write-PackagerLog.ps1 @@ -0,0 +1,37 @@ +function Write-PackagerLog { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$Message, + [ValidateSet("Info", "Warning", "Error")][string]$Severity = "Info" + ) + + $LogPath = "$env:ProgramData\TecharyGet\InstallLogs.log" + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $Line = "[$Timestamp] [$Severity] $Message" + + # 1. Console Output + $Color = switch ($Severity) { "Info" {"Green"} "Warning" {"Yellow"} "Error" {"Red"} } + Write-Host $Line -ForegroundColor $Color + + # 2. File Log + if (-not (Test-Path (Split-Path $LogPath))) { New-Item -ItemType Directory (Split-Path $LogPath) -Force | Out-Null } + Add-Content -Path $LogPath -Value $Line + + # 3. ENTERPRISE EVENT LOGGING (New!) + # N-able can pick this up easily. + # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) + + $EventSource = "TecharyGet" + if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { + # Requires Admin to create source once. + # If not admin, this skips silently to avoid crashing. + try { New-EventLog -LogName Application -Source $EventSource -ErrorAction SilentlyContinue } catch {} + } + + if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { + $EventID = switch ($Severity) { "Info" {100} "Warning" {200} "Error" {300} } + $EntryType = switch ($Severity) { "Info" {"Information"} "Warning" {"Warning"} "Error" {"Error"} } + + Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message + } +} \ No newline at end of file From 30ee674a0d263c8b68c91a05d8e71d1aa10d0802 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:42:04 +0000 Subject: [PATCH 29/50] Update TecharyGet.psd1 --- TecharyGet.psd1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 9f0c8fe..e48bbce 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '0.1' + ModuleVersion = '2.0' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -99,3 +99,4 @@ # Format files (.ps1xml) to be loaded when importing this module # FormatsToProcess = @() } + From f3293557984869340a9e22bfa56c0f76733f2701 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:14:48 +0000 Subject: [PATCH 30/50] Delete Public/aa.txt --- Public/aa.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Public/aa.txt diff --git a/Public/aa.txt b/Public/aa.txt deleted file mode 100644 index e61ef7b..0000000 --- a/Public/aa.txt +++ /dev/null @@ -1 +0,0 @@ -aa From b58ab70d62483dc957571260897b98e4408b5dd9 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:19:47 +0000 Subject: [PATCH 31/50] Update TecharyGet.psd1 --- TecharyGet.psd1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index e48bbce..196d6fc 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '2.0' + ModuleVersion = '2.1' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -100,3 +100,4 @@ # FormatsToProcess = @() } + From 3f033ade665e49de29e75c960036f52493f2f3ed Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:52:10 +0000 Subject: [PATCH 32/50] Update TecharyGet.psd1 --- TecharyGet.psd1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 196d6fc..33425a3 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '2.1' + ModuleVersion = '2.2' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -101,3 +101,4 @@ } + From c53aa2592f30c1f9d5968ab152d77cbe4668b4a1 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:06:22 +0000 Subject: [PATCH 33/50] Refactor Install-NableAgent function Updated Install-NableAgent function to improve parameter handling and validation logic. --- Public/Install-NableAgent.ps1 | 113 +++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/Public/Install-NableAgent.ps1 b/Public/Install-NableAgent.ps1 index 7ba9d31..2662fef 100644 --- a/Public/Install-NableAgent.ps1 +++ b/Public/Install-NableAgent.ps1 @@ -1,80 +1,107 @@ function Install-NableAgent { [CmdletBinding()] param ( - [Parameter(Mandatory=$true)][string]$CustomerID, - [Parameter(Mandatory=$true)][string]$Token, - [Parameter(Mandatory=$true)][string]$CustomerName, - [Parameter(Mandatory=$true)][string]$ServerAddress, - [int]$TimeoutSeconds = 1200 # 20 minutes + [Parameter(Mandatory = $true)] + [string]$CustomerID, + + [Parameter(Mandatory = $true)] + [string]$Token, + + [Parameter(Mandatory = $false)] + [string]$CustomerName, + + [Parameter(Mandatory = $true)] + [string]$ServerAddress, + + [int]$TimeoutSeconds = 600 ) $Name = "N-able RMM Agent" - $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" - $DownloadPath = "$env:TEMP\AppPackager" + $DownloadPath = "C:\Temp\AppPackager" $FileName = "Nable_RMMInstaller.exe" $InstallerPath = Join-Path $DownloadPath $FileName - - Write-PackagerLog -Message "Starting N-able Deployment for Customer: $CustomerName" + $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" try { - # 1. Download - if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + Write-PackagerLog -Message "Starting N-able Deployment for Customer: $CustomerName" + + if (-not (Test-Path $DownloadPath)) { + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + } + + if (Test-Path $InstallerPath) { + Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue + } Write-PackagerLog -Message "Downloading installer from: $DownloadUrl" - - # We use a spoofed UserAgent just in case N-able blocks scripts, though usually not required here. - $UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - Invoke-WebRequest -Uri $DownloadUrl -OutFile $InstallerPath -UseBasicParsing -UserAgent $UserAgent - - if (-not (Test-Path $InstallerPath)) { throw "Download failed. File not found." } - - # 2. Build Arguments (Standard InstallShield format) - # /S = Silent for the wrapper - # /v = Pass arguments to internal MSI - # We quote the MSI arguments carefully so Install-AppPackage regex keeps them together. - - $MsiArgs = "/qn CUSTOMERID=$CustomerID CUSTOMERNAME=$CustomerName CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$Token SERVERPROTOCOL=HTTPS SERVERADDRESS=$ServerAddress SERVERPORT=443" - - # NOTE: We construct the final string. Install-AppPackage's Regex will see "/v"..." " as one argument. - $FinalArgs = "/S /v`"$MsiArgs`"" - - # 3. Install (Using your module's executor) - Install-AppPackage -Name $Name -FilePath $InstallerPath -Arguments $FinalArgs - - # 4. Cleanup Installer immediately - Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue - - # 5. Validation Loop (The "Is it actually working?" check) + + Invoke-WebRequest ` + -Uri $DownloadUrl ` + -OutFile $InstallerPath ` + -UseBasicParsing ` + -UserAgent "Mozilla/5.0" + + if (-not (Test-Path $InstallerPath)) { + throw "Download failed. File not found." + } + + Unblock-File -Path $InstallerPath -ErrorAction SilentlyContinue + + # If N-able requires the customer name value literally as: + # '\"Techary Internal\"' + # $FormattedCustomerName = "\`"$CustomerName\`"" + + # Build arguments EXACTLY like the working script pattern + $MsiArgs = "/qn CUSTOMERID=$CustomerID CUSTOMERSPECIFIC=1 REGISTRATION_TOKEN=$Token SERVERPROTOCOL=HTTPS SERVERADDRESS=$ServerAddress SERVERPORT=443" + $Arguments = "/S /v`"$MsiArgs`"" + + Write-PackagerLog -Message "Executing N-able installer..." + Write-PackagerLog -Message "Executor: $InstallerPath" + Write-PackagerLog -Message "Final Args: $Arguments" + + $Process = Start-Process ` + -FilePath $InstallerPath ` + -ArgumentList $Arguments ` + -Wait ` + -PassThru ` + -NoNewWindow + + Write-PackagerLog -Message "Installer finished with exit code: $($Process.ExitCode)" + + # Do NOT fail immediately on exit code. + # The working script's real success criteria is service validation. Write-PackagerLog -Message "Starting Post-Install Validation (Timeout: ${TimeoutSeconds}s)..." - + $StartTime = Get-Date - + while ($true) { # Check Services and Files $Service1 = Get-Service -Name "Windows Agent Service" -ErrorAction SilentlyContinue $Service2 = Get-Service -Name "N-able Take Control Service (N-Central)" -ErrorAction SilentlyContinue $FileCheck = Test-Path "C:\Program Files (x86)\BeAnywhere Support Express\GetSupportService_N-Central\uninstall.exe" - if ($Service1 -and $Service2 -and $FileCheck) { + $Service1Running = $Service1 -and $Service1.Status -eq "Running" + $Service2Running = $Service2 -and $Service2.Status -eq "Running" + + if ($Service1Running -and $Service2Running -and $FileCheck) { Write-PackagerLog -Message "Validation Successful: N-able services are running." - return # Success! + Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue + return } # Check Timeout $Elapsed = (Get-Date) - $StartTime if ($Elapsed.TotalSeconds -ge $TimeoutSeconds) { Write-PackagerLog -Message "Validation Timed Out. Services did not start in time." -Severity Error - throw "N-able installation finished, but validation failed (Timeout)." + throw "N-able installation finished, but validation failed (Timeout). ExitCode=$($Process.ExitCode)" } Write-PackagerLog -Message "Waiting for services to start..." Start-Sleep -Seconds 10 } - } catch { Write-PackagerLog -Message "N-able Deployment Failed: $_" -Severity Error throw $_ } -} \ No newline at end of file +} From ec8c1783de3647648c95506d3e480a982bb8b50f Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:07:39 +0000 Subject: [PATCH 34/50] Update TecharyGet.psd1 --- TecharyGet.psd1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 33425a3..9b33dbc 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '2.2' + ModuleVersion = '2.3' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -102,3 +102,4 @@ + From f20deadbd1ea232ecd0713af8c2f7c432059fe3a Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:40:15 +0000 Subject: [PATCH 35/50] Update README with Nable Agent installation example Added installation instructions for Nable Agent. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index ae4da47..b7fb81f 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,13 @@ Install-TecharyApp -Id "7zip.7zip" # Install a specific ID from your private catalog Install-TecharyApp -Id "MyDPD" +``` + +For the Nable Agent; it is slightly different, below is a example + +```powershell +Install-NableAgent ` + -CustomerID "" ` + -Token "" ` + -ServerAddress "" +``` From fe90072526fc6b310d962433df87cdda56076762 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:14:54 +0000 Subject: [PATCH 36/50] Update Get-GitHubInstaller.ps1 --- Public/Get-GitHubInstaller.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 index 747828f..86e5b08 100644 --- a/Public/Get-GitHubInstaller.ps1 +++ b/Public/Get-GitHubInstaller.ps1 @@ -88,6 +88,9 @@ function Get-GitHubInstaller { # Special Override for Dell (Command Update) if ($Id -eq "Dell.CommandUpdate") { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + # Special Override for 8x8 Work MSI + if ($Id -eq "8x8.Work") { $SelectedArgs = "/qn /norestart" } + # --- DOWNLOAD --- $UriObj = [System.Uri]$SelectedUrl $RealExtension = [System.IO.Path]::GetExtension($UriObj.LocalPath).ToLower() @@ -114,4 +117,5 @@ function Get-GitHubInstaller { Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error throw $_ } -} \ No newline at end of file + +} From cbac32dda0eda89f137e5651cb6daa2308285898 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:11:58 +0000 Subject: [PATCH 37/50] Update Get-GitHubInstaller.ps1 --- Public/Get-GitHubInstaller.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 index 86e5b08..c028dd0 100644 --- a/Public/Get-GitHubInstaller.ps1 +++ b/Public/Get-GitHubInstaller.ps1 @@ -91,6 +91,9 @@ function Get-GitHubInstaller { # Special Override for 8x8 Work MSI if ($Id -eq "8x8.Work") { $SelectedArgs = "/qn /norestart" } + # Special Override for Sublime Text 4 + if ($Id -eq "SublimeHQ.SublimeText.4") { $SelectedArgs = "/VERYSILENT /NORESTART" } + # --- DOWNLOAD --- $UriObj = [System.Uri]$SelectedUrl $RealExtension = [System.IO.Path]::GetExtension($UriObj.LocalPath).ToLower() @@ -117,5 +120,4 @@ function Get-GitHubInstaller { Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error throw $_ } - } From 11db0ca44a9851e14740eb52e40862e7c2e0f961 Mon Sep 17 00:00:00 2001 From: TecharyAdam <86409453+TecharyAdam@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:27:39 +0000 Subject: [PATCH 38/50] Update README with App ID details Added information about the App ID for installation. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b7fb81f..6746164 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 🚀 Key Features * **Smart Installation:** Automatically fetches the latest version of apps from GitHub (WinGet Manifests) or your Private Cloud Catalog. +* **App ID:** The app name for installation is the Winget ID which can either be found using **Winget Search ** or via the Winget PKGs repo. * **Architecture Detection:** Automatically selects the correct installer (x64, x86, ARM64) for the target machine. * **Enterprise Logging:** Writes detailed logs to both `C:\ProgramData\TecharyGet\InstallLogs.log` and the **Windows Event Log** (Source: `TecharyGet`) for RMM monitoring. * **Intune Packaging:** Instantly generates `.intunewin` files with "Thin" scripts that trigger installs dynamically. From 04ed3fb61551dec962a5d4dc226a21c3d2d0f690 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 11:48:05 +0100 Subject: [PATCH 39/50] Cut GitHub API dependency and fix silent-failure paths Reduces the unauthenticated api.github.com dependency and fixes a set of defects that made failures look like successes. GitHub API rate limiting Get-GitHubInstaller made two api.github.com calls per install with no credentials. That allowance is 60 requests/hour per source IP, so a site behind a single NAT egress gets roughly 30 installs an hour before every subsequent install fails at the resolve step. Three layers now sit in front of it: - resolved manifests are cached to ProgramData\TecharyGet\ManifestCache, so repeat and retry installs of the same app cost no API calls - on a live failure the cache is reused even when stale, so a rate limited or offline site keeps installing from last known good instead of hard failing - an optional PAT (-GitHubToken, or TECHARYGET_GITHUB_TOKEN) lifts the allowance to 5000/hour, and a rate limit response now says so The scraping logic moves to Private/Resolve-GitHubManifest.ps1 so resolution and download are separable and the cache has a seam to sit on. Custom catalog was never reachable Get-CustomApp pointed at .../BETA/TecharyGet/Private/CustomApps.json. There is no TecharyGet directory in this repo, so that URL 404s on every call and the catalog never synced. Corrected to the real path. The download is also staged and parsed before it replaces the cache, so a captive portal or proxy page returning HTTP 200 with HTML can no longer poison the cache for an hour. Failures that reported as success - Install-TecharyApp returned silently for an unknown Id. A caller driving this from Intune or an RMM cannot tell that from a successful install, so a mistyped Id was reported as success. It now throws. - Write-PackagerLog called EventLog::SourceExists unguarded. That throws SecurityException when the caller cannot read the event log registry, which is the normal non-elevated case, so every log call became a terminating error. Event logging is now best effort and the file log is likewise non-fatal. - Uninstall-TecharyApp left $Arguments unset when an MSI uninstall string contained no product code, reaching Start-Process as $null. Both variables are initialised and that case now reports the reason. - Uninstall-TecharyApp appended /S /silent /quiet /norestart to QuietUninstallString, which is silent by definition. Passing contradictory flags made some vendors' uninstallers fail or fall back to a UI prompt. The switches are only appended to a plain UninstallString now. Version selection Manifest versions were cast to [Version] and the failures discarded, which silently dropped real releases such as 1.2.3-beta and 20240101, and threw outright when no folder happened to parse. Replaced with a zero padded sortable key over fixed width components, which also fixes 1.2 sorting above 1.2.3, and prefers a stable release over a prerelease on a tie. Module loading and manifest The loader used $MyInvocation.MyCommand.Path, errored on a missing directory and reported a failed dot-source as a later "command not found". It now uses $PSScriptRoot, skips absent directories and fails immediately naming the file and the reason. FunctionsToExport advertised Show-IntunePackager, which has no implementation; removed. Version 2.4. Verified: all files parse, Test-ModuleManifest passes, the module imports and exports 8 commands, live resolution succeeds for 7zip.7zip, Notepad++.Notepad++ and Google.Chrome, and version ordering is asserted across mixed release, date and prerelease formats. --- Private/Get-CustomApp.ps1 | 31 +++-- Private/Resolve-GitHubManifest.ps1 | 114 ++++++++++++++++++ Public/Get-GitHubInstaller.ps1 | 186 ++++++++++++++++------------- Public/Install-TecharyApp.ps1 | 56 +++++---- Public/Uninstall-TecharyApp.ps1 | 53 +++++--- Public/Write-PackagerLog.ps1 | 43 ++++--- TecharyGet.psd1 | 3 +- TecharyGet.psm1 | 29 ++++- 8 files changed, 362 insertions(+), 153 deletions(-) create mode 100644 Private/Resolve-GitHubManifest.ps1 diff --git a/Private/Get-CustomApp.ps1 b/Private/Get-CustomApp.ps1 index 4a6cae4..28ca71e 100644 --- a/Private/Get-CustomApp.ps1 +++ b/Private/Get-CustomApp.ps1 @@ -6,18 +6,20 @@ function Get-CustomApp { # --- 1. CLOUD SOURCE --- # We use the "Raw" GitHub URL so we get just the JSON text. # Structure: https://raw.githubusercontent.com//// - $CloudUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json" - + # raw.githubusercontent.com is CDN-backed and is NOT subject to the + # api.github.com rate limit, so this stays cheap at fleet scale. + $CloudUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/BETA/Private/CustomApps.json" + # --- 2. LOCAL CACHE --- # We cache the file locally so the script works even if GitHub is briefly down # or if the machine is offline (using the last known good copy). $CacheDir = "$env:PROGRAMDATA\TecharyGet" $CachePath = "$CacheDir\CustomApps_Cache.json" - + # --- 3. SYNC LOGIC --- try { if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } - + # Logic: Only download if the cache doesn't exist OR it's older than 60 minutes. # This prevents spamming GitHub every time you run a command. $NeedUpdate = $true @@ -28,11 +30,20 @@ function Get-CustomApp { if ($NeedUpdate) { Write-PackagerLog -Message "Syncing Custom Catalog from GitHub..." -Severity Info - Invoke-WebRequest -Uri $CloudUrl -OutFile $CachePath -UseBasicParsing -ErrorAction Stop + + # Download to a staging file and prove it parses before promoting it. + # A captive portal or proxy error page returns HTTP 200 with HTML, + # which would otherwise poison the cache for the next 60 minutes. + $StagePath = "$CachePath.tmp" + Invoke-WebRequest -Uri $CloudUrl -OutFile $StagePath -UseBasicParsing -ErrorAction Stop + + $null = (Get-Content -Path $StagePath -Raw | ConvertFrom-Json) + Move-Item -Path $StagePath -Destination $CachePath -Force -ErrorAction Stop } } catch { - Write-PackagerLog -Message "Could not sync from GitHub (Offline?). Using local cache." -Severity Warning + Write-PackagerLog -Message "Could not sync Custom Catalog ($($_.Exception.Message)). Using local copy." -Severity Warning + Remove-Item "$CachePath.tmp" -Force -ErrorAction SilentlyContinue } # --- 4. READ DATA --- @@ -45,8 +56,8 @@ function Get-CustomApp { # Fallback to the file shipped with the module (if cache is empty/broken) else { $LocalModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Private\CustomApps.json" - if (Test-Path $LocalModulePath) { - $JsonContent = Get-Content -Path $LocalModulePath -Raw + if (Test-Path $LocalModulePath) { + $JsonContent = Get-Content -Path $LocalModulePath -Raw } } @@ -62,6 +73,6 @@ function Get-CustomApp { return $null } } - + return $null -} \ No newline at end of file +} diff --git a/Private/Resolve-GitHubManifest.ps1 b/Private/Resolve-GitHubManifest.ps1 new file mode 100644 index 0000000..d69ab32 --- /dev/null +++ b/Private/Resolve-GitHubManifest.ps1 @@ -0,0 +1,114 @@ +function Get-ManifestVersionKey { + param([string]$Name) + + # winget version folders are not reliably [Version]-parseable: "1.2.3-beta", + # "20240101", "2.0" and "1.2.3.4.5" all occur. Casting to [Version] and + # discarding the failures silently dropped real releases, and threw outright + # for packages where no folder happened to parse. Build a zero-padded key so + # ordinary string sorting gives correct numeric ordering instead. + $Numbers = [regex]::Matches($Name, '\d+') | ForEach-Object { $_.Value } + if (-not $Numbers) { return $null } + + # Always emit the same number of components. With variable-length keys + # "1.2" sorted ABOVE "1.2.3", because the separator that terminated the + # shorter key compared higher than the '.' in the longer one. + $Parts = New-Object System.Collections.Generic.List[string] + foreach ($N in ($Numbers | Select-Object -First 6)) { + try { $Parts.Add('{0:D12}' -f [int64]$N) } catch { $Parts.Add('{0:D12}' -f 0) } + } + while ($Parts.Count -lt 6) { $Parts.Add('{0:D12}' -f 0) } + + # Tiebreak on equal numbers: prefer the stable release over a prerelease, + # so "1.2.3" beats "1.2.3-beta" instead of the winner being arbitrary. + $Rank = if ($Name -match '[A-Za-z]') { '0' } else { '9' } + + return (($Parts -join '.') + '|' + $Rank) +} + +function Resolve-GitHubManifest { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$Id, + [Parameter(Mandatory=$true)][string]$SysArch, + [Parameter(Mandatory=$true)][hashtable]$Headers + ) + + # 2. Construct API Path + $IdPath = $Id.Replace(".", "/") + $FirstChar = $Id.Substring(0,1).ToLower() + $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" + + # 3. Get Version (Latest) [API call 1 of 2] + $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -Headers $Headers -ErrorAction Stop + + $LatestVersionObj = $VersionsResponse | + Where-Object { $_.type -eq "dir" } | + Select-Object *, @{N='SortKey'; E={ Get-ManifestVersionKey -Name $_.name }} | + Where-Object { $null -ne $_.SortKey } | + Sort-Object SortKey -Descending | + Select-Object -First 1 + + if (-not $LatestVersionObj) { throw "Could not determine a valid version folder for '$Id'." } + $LatestVersion = $LatestVersionObj.Name + + # 4. Get Manifest [API call 2 of 2] + $VersionPath = "$BaseApi/$LatestVersion" + $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get -Headers $Headers -ErrorAction Stop + $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 + if (-not $InstallerFile) { throw "No installer YAML found for '$Id' $LatestVersion." } + + # Served from raw.githubusercontent.com, which is CDN-backed and not + # subject to the API rate limit, so no credentials are sent here. + $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url -Headers @{ 'User-Agent' = 'TecharyGet' } -ErrorAction Stop + + # --- PARSING LOGIC --- + # We split by "- Architecture" to separate blocks, but keep the delimiter to help identification + $Blocks = $YamlContent -split '(?=-\s*Architecture:)' + + $SelectedUrl = $null + $SelectedArgs = $null + $SelectedType = "exe" + $SelectedCode = $null + + foreach ($Block in $Blocks) { + if ([string]::IsNullOrWhiteSpace($Block)) { continue } + + if ($Block -match 'Architecture:\s*([a-zA-Z0-9]+)') { + $BlockArch = $Matches[1].Trim() + + if ($BlockArch -eq $SysArch) { + if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } + if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } + + if ($Block -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + elseif ($Block -match 'SilentWithProgress:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + + if ($Block -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + + if ($SelectedUrl) { break } + } + } + } + + # Fallbacks (Global properties if not in block) + if (-not $SelectedUrl) { if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } } + if (-not $SelectedArgs) { + if ($YamlContent -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + } + if (-not $SelectedCode) { + if ($YamlContent -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + } + + if (-not $SelectedUrl) { throw "No InstallerUrl found in the manifest for '$Id' $LatestVersion." } + + return [PSCustomObject]@{ + Id = $Id + Version = $LatestVersion + Arch = $SysArch + Url = $SelectedUrl + SilentArgs = $SelectedArgs + InstallerType = $SelectedType + ProductCode = $SelectedCode + ResolvedUtc = (Get-Date).ToUniversalTime().ToString('o') + } +} diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 index c028dd0..83c10a6 100644 --- a/Public/Get-GitHubInstaller.ps1 +++ b/Public/Get-GitHubInstaller.ps1 @@ -3,96 +3,110 @@ function Get-GitHubInstaller { param ( [Parameter(Mandatory=$true)] [string]$Id, - [string]$DownloadPath = "$env:TEMP\AppPackager" + [string]$DownloadPath = "$env:TEMP\AppPackager", + + # A PAT lifts the GitHub API allowance from 60 to 5000 requests/hour. + # Falls back to the environment so endpoints can be seeded centrally + # without the token appearing in a command line or an RMM job log. + [string]$GitHubToken = $env:TECHARYGET_GITHUB_TOKEN, + + # Resolved manifests are cached on disk. Repeat and retry installs of + # the same app then cost no API calls at all. + [int]$CacheHours = 24, + [switch]$NoCache ) Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" - try { - # 1. Detect Architecture - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } - elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } - else { $SysArch = "x86" } - - # 2. Construct API Path - $IdPath = $Id.Replace(".", "/") - $FirstChar = $Id.Substring(0,1).ToLower() - $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" - - # 3. Get Version (Latest) - $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -ErrorAction Stop - $LatestVersionObj = $VersionsResponse | - Where-Object { $_.type -eq "dir" } | - Select-Object *, @{N='ParsedVersion'; E={ try { [Version]$_.name } catch { $null } }} | - Where-Object { $_.ParsedVersion -ne $null } | - Sort-Object ParsedVersion -Descending | - Select-Object -First 1 - - if (-not $LatestVersionObj) { throw "Could not determine a valid numeric version." } - $LatestVersion = $LatestVersionObj.Name - - # 4. Get Manifest - $VersionPath = "$BaseApi/$LatestVersion" - $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get - $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 - if (-not $InstallerFile) { throw "No installer YAML found." } - - $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url - - # --- PARSING LOGIC --- - # We split by "- Architecture" to separate blocks, but keep the delimiter to help identification - $Blocks = $YamlContent -split '(?=-\s*Architecture:)' - - $SelectedUrl = $null - $SelectedArgs = $null - $SelectedType = "exe" - $SelectedCode = $null - - foreach ($Block in $Blocks) { - if ([string]::IsNullOrWhiteSpace($Block)) { continue } - - # Extract Architecture from this block - if ($Block -match 'Architecture:\s*([a-zA-Z0-9]+)') { - $BlockArch = $Matches[1].Trim() - - # If this block matches our system, scrape it! - if ($BlockArch -eq $SysArch) { - if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } - if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } - - # Scrape Arguments - if ($Block -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - elseif ($Block -match 'SilentWithProgress:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - - # Scrape Product Code (Flexible Regex) - # This now matches "{GUID}" OR "SimpleString" - if ($Block -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { - $SelectedCode = $Matches[1].Trim() - } - - # If we found a URL, we stop looking (we prefer the first match for our arch) - if ($SelectedUrl) { break } + # PS 5.1 on older builds still negotiates TLS 1.0 by default, which GitHub refuses. + try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + + $CacheDir = "$env:ProgramData\TecharyGet\ManifestCache" + $CacheFile = Join-Path $CacheDir ("$Id.json" -replace '[\/:*?"<>|]', '_') + + $Headers = @{ 'User-Agent' = 'TecharyGet' } + if ($GitHubToken) { $Headers['Authorization'] = "token $GitHubToken" } + + # 1. Detect Architecture + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # --- RESOLVE MANIFEST METADATA ------------------------------------- + # Order: fresh cache -> live API -> stale cache. The stale fallback is + # what keeps a rate-limited or offline site installing software. + $Meta = $null + + if (-not $NoCache -and (Test-Path $CacheFile)) { + $Age = (Get-Date) - (Get-Item $CacheFile).LastWriteTime + if ($Age.TotalHours -lt $CacheHours) { + try { + $Cached = Get-Content $CacheFile -Raw | ConvertFrom-Json + if ($Cached.Arch -eq $SysArch -and $Cached.Url) { + Write-PackagerLog -Message "Using cached manifest for $Id (v$($Cached.Version), $([int]$Age.TotalHours)h old). No API call needed." + $Meta = $Cached } } + catch { + Write-PackagerLog -Message "Manifest cache for $Id unreadable, re-resolving." -Severity Warning + } } + } - # Fallbacks (Global properties if not in block) - if (-not $SelectedUrl) { if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } } - if (-not $SelectedArgs) { - if ($YamlContent -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - } - if (-not $SelectedCode) { - if ($YamlContent -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + if (-not $Meta) { + try { + $Meta = Resolve-GitHubManifest -Id $Id -SysArch $SysArch -Headers $Headers + + try { + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } + $Meta | ConvertTo-Json -Depth 4 | Set-Content -Path $CacheFile -Encoding UTF8 -ErrorAction Stop + } + catch { + Write-PackagerLog -Message "Could not cache manifest for ${Id}: $($_.Exception.Message)" -Severity Warning + } } + catch { + $Reason = $_.Exception.Message + $IsRateLimit = $Reason -match '\(403\)|rate limit' + + if ($IsRateLimit -and -not $GitHubToken) { + Write-PackagerLog -Message "GitHub API rate limit hit (60/hour per public IP, unauthenticated). Supply -GitHubToken or set TECHARYGET_GITHUB_TOKEN to raise this to 5000/hour." -Severity Warning + } + + if (-not $NoCache -and (Test-Path $CacheFile)) { + try { + $Stale = Get-Content $CacheFile -Raw | ConvertFrom-Json + if ($Stale.Arch -eq $SysArch -and $Stale.Url) { + $StaleAge = [int]((Get-Date) - (Get-Item $CacheFile).LastWriteTime).TotalHours + Write-PackagerLog -Message "Live resolve failed ($Reason). Falling back to cached manifest for $Id, ${StaleAge}h old (v$($Stale.Version))." -Severity Warning + $Meta = $Stale + } + } + catch { } + } - # Special Override for Dell (Command Update) - if ($Id -eq "Dell.CommandUpdate") { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + if (-not $Meta) { + Write-PackagerLog -Message "GitHub Scraping Failed: $Reason" -Severity Error + throw $_ + } + } + } - # Special Override for 8x8 Work MSI - if ($Id -eq "8x8.Work") { $SelectedArgs = "/qn /norestart" } + try { + $SelectedUrl = $Meta.Url + $SelectedArgs = $Meta.SilentArgs + $SelectedType = $Meta.InstallerType + $SelectedCode = $Meta.ProductCode + $LatestVersion = $Meta.Version - # Special Override for Sublime Text 4 - if ($Id -eq "SublimeHQ.SublimeText.4") { $SelectedArgs = "/VERYSILENT /NORESTART" } + # --- PER-PACKAGE OVERRIDES --- + # Applied after the cache read so a correction here takes effect + # immediately rather than waiting for the cache to expire. + switch ($Id) { + "Dell.CommandUpdate" { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + "8x8.Work" { $SelectedArgs = "/qn /norestart" } + "SublimeHQ.SublimeText.4" { $SelectedArgs = "/VERYSILENT /NORESTART" } + } # --- DOWNLOAD --- $UriObj = [System.Uri]$SelectedUrl @@ -103,21 +117,23 @@ function Get-GitHubInstaller { if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null $FullPath = Join-Path $DownloadPath $FileName - + + # The installer itself comes from the vendor CDN, not the GitHub API, + # so it is never rate limited. Write-PackagerLog -Message "Downloading to $FullPath..." Invoke-WebRequest -Uri $SelectedUrl -OutFile $FullPath -UseBasicParsing -UserAgent "Mozilla/5.0" - + return [PSCustomObject]@{ - Name = $Id + Name = $Id InstallerPath = $FullPath - FileName = $FileName - SilentArgs = $SelectedArgs + FileName = $FileName + SilentArgs = $SelectedArgs InstallerType = $SelectedType - ProductCode = $SelectedCode + ProductCode = $SelectedCode } } catch { - Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error + Write-PackagerLog -Message "Installer download failed: $_" -Severity Error throw $_ } } diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 index d54e73a..fbf2ce8 100644 --- a/Public/Install-TecharyApp.ps1 +++ b/Public/Install-TecharyApp.ps1 @@ -4,14 +4,18 @@ function Install-TecharyApp { [CmdletBinding()] param( [Parameter(Mandatory=$true)] - [string]$Id + [string]$Id, + + # Passed through to the winget-pkgs manifest lookup. Raises the GitHub + # API allowance from 60 to 5000 requests/hour where one is available. + [string]$GitHubToken = $env:TECHARYGET_GITHUB_TOKEN ) - + $Pkg = $null # --- ATTEMPT 1: GITHUB --- try { - $Pkg = Get-GitHubInstaller -Id $Id -ErrorAction Stop + $Pkg = Get-GitHubInstaller -Id $Id -GitHubToken $GitHubToken -ErrorAction Stop } catch { Write-PackagerLog -Message "Not found in GitHub ($Id). Checking Custom Catalog..." -Severity Info @@ -22,24 +26,20 @@ function Install-TecharyApp { # Load the internal helper to check JSON # (Assuming Get-CustomApp is dot-sourced in .psm1) $CustomData = Get-CustomApp -Id $Id - + if ($CustomData) { Write-PackagerLog -Message "Found '$Id' in Custom Catalog." - - # Use the Web Installer logic to download it - # We can reuse the logic or call Get-WebInstaller if you created it. - # Here is the inline logic for simplicity: - + $DownloadPath = "$env:TEMP\AppPackager" if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null - + $FileName = "$Id.$($CustomData.InstallerType)" $FullPath = Join-Path $DownloadPath $FileName - + Write-PackagerLog -Message "Downloading Custom App from: $($CustomData.Url)" Invoke-WebRequest -Uri $CustomData.Url -OutFile $FullPath -UseBasicParsing - + # Build the Package Object manually $Pkg = [PSCustomObject]@{ Name = $Id @@ -52,17 +52,29 @@ function Install-TecharyApp { } if (-not $Pkg) { - Write-PackagerLog -Message "Application '$Id' not found in GitHub OR Custom Catalog." -Severity Error - return + $Msg = "Application '$Id' not found in GitHub OR Custom Catalog." + Write-PackagerLog -Message $Msg -Severity Error + # Throw rather than return. A caller driving this from Intune or an RMM + # remediation cannot distinguish a silent return from a successful + # install, so a mistyped Id would be reported to the console as success. + throw $Msg } - + # --- INSTALLATION --- # MSI Fallback Logic - $Args = $Pkg.SilentArgs - if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { - $Args = "/qb /norestart" + # Named InstallArgs, not Args: $Args is an automatic variable and assigning + # to it is undefined behaviour under Set-StrictMode. + $InstallArgs = $Pkg.SilentArgs + if ([string]::IsNullOrWhiteSpace($InstallArgs) -and ($Pkg.InstallerPath -match "\.msi$" -or $Pkg.InstallerType -eq "msi")) { + $InstallArgs = "/qb /norestart" + } + + try { + Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $InstallArgs + } + finally { + # Clean up even when the install throws, so a failed run does not + # leave an installer behind for the next one to trip over. + Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force } - - Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args - Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force -} \ No newline at end of file +} diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index 8b4fdd6..e47b9f5 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -3,7 +3,7 @@ function Uninstall-TecharyApp { param ( [Parameter(Mandatory=$true)] [string]$Name, - + [switch]$WhatIf ) @@ -18,8 +18,8 @@ function Uninstall-TecharyApp { $App = $null foreach ($Path in $Paths) { - $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "*$Name*" } | + $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "*$Name*" } | Select-Object -First 1 if ($App) { break } } @@ -28,17 +28,17 @@ function Uninstall-TecharyApp { if (-not $App) { Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue - + if ($MsixResults) { # FIX: Handle cases where multiple apps match (Array vs Single Object) foreach ($Package in $MsixResults) { Write-PackagerLog -Message "Found Modern App: $($Package.Name)" - - if ($WhatIf) { + + if ($WhatIf) { Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow - continue + continue } - + try { Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop Write-PackagerLog -Message "Success: Removed $($Package.Name)" @@ -49,13 +49,16 @@ function Uninstall-TecharyApp { } return } - + Write-PackagerLog -Message "Application '$Name' not found on this system." -Severity Warning return } # 3. DETERMINE UNINSTALL COMMAND (Classic Apps) + # Both must be initialised: an unset $Arguments previously reached + # Start-Process as $null whenever the MSI branch failed to match a GUID. $UninstallString = $null + $Arguments = "" $Type = "EXE" if ($App.UninstallString -match "MsiExec.exe") { @@ -65,10 +68,19 @@ function Uninstall-TecharyApp { $UninstallString = "msiexec.exe" $Arguments = "/x $Guid /qn /norestart" } + else { + Write-PackagerLog -Message "MSI uninstall string for '$($App.DisplayName)' contains no product code: $($App.UninstallString)" -Severity Error + return + } } else { # EXE Uninstaller logic - if ($App.QuietUninstallString) { + # QuietUninstallString is silent by definition. Appending our own + # switches to it passed contradictory flags to the uninstaller, which + # made some vendors' uninstallers fail or fall back to a UI prompt. + $UsedQuietString = [bool]$App.QuietUninstallString + + if ($UsedQuietString) { $RawString = $App.QuietUninstallString } else { $RawString = $App.UninstallString @@ -77,16 +89,21 @@ function Uninstall-TecharyApp { if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } $ArgsPart = $Matches[3].Trim() - + $UninstallString = $Exe $Arguments = $ArgsPart - - if (-not ($Arguments -match "/S|/silent|/qn|/quiet")) { + + if (-not $UsedQuietString -and -not ($Arguments -match "/S|/silent|/qn|/quiet")) { $Arguments = "$Arguments /S /silent /quiet /norestart" } } } + if (-not $UninstallString) { + Write-PackagerLog -Message "Could not derive an uninstall command for '$($App.DisplayName)' from: $($App.UninstallString)" -Severity Error + return + } + Write-PackagerLog -Message "Found: $($App.DisplayName) ($Type)" Write-PackagerLog -Message "Command: $UninstallString $Arguments" @@ -97,8 +114,12 @@ function Uninstall-TecharyApp { # 4. EXECUTE REMOVAL try { - $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow - + if ([string]::IsNullOrWhiteSpace($Arguments)) { + $Process = Start-Process -FilePath $UninstallString -PassThru -Wait -NoNewWindow + } else { + $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow + } + if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { Write-PackagerLog -Message "Uninstallation Successful." } else { @@ -108,4 +129,4 @@ function Uninstall-TecharyApp { catch { Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error } -} \ No newline at end of file +} diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 index ed2d4ee..783d509 100644 --- a/Public/Write-PackagerLog.ps1 +++ b/Public/Write-PackagerLog.ps1 @@ -14,24 +14,39 @@ function Write-PackagerLog { Write-Host $Line -ForegroundColor $Color # 2. File Log - if (-not (Test-Path (Split-Path $LogPath))) { New-Item -ItemType Directory (Split-Path $LogPath) -Force | Out-Null } - Add-Content -Path $LogPath -Value $Line + # Never let logging take down the caller. A locked log file or a + # read-only ProgramData must not abort an install half way through. + try { + $LogDir = Split-Path $LogPath + if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory $LogDir -Force -ErrorAction Stop | Out-Null } + Add-Content -Path $LogPath -Value $Line -ErrorAction Stop + } + catch { + Write-Warning "TecharyGet: could not write to $LogPath ($($_.Exception.Message))." + } - # 3. ENTERPRISE EVENT LOGGING (New!) + # 3. ENTERPRISE EVENT LOGGING # N-able can pick this up easily. # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) - - $EventSource = "TecharyGet" - if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { - # Requires Admin to create source once. - # If not admin, this skips silently to avoid crashing. - try { New-EventLog -LogName Application -Source $EventSource -ErrorAction SilentlyContinue } catch {} - } + # + # SourceExists() enumerates the whole event-log registry key and throws a + # SecurityException when the caller cannot read it, which is the normal + # case for a non-elevated user. Unguarded, that turned every single log + # call into a terminating error. + try { + $EventSource = "TecharyGet" + + if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) { + # Creating a source requires admin. Skip quietly if we cannot. + New-EventLog -LogName Application -Source $EventSource -ErrorAction Stop + } - if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { $EventID = switch ($Severity) { "Info" {100} "Warning" {200} "Error" {300} } $EntryType = switch ($Severity) { "Info" {"Information"} "Warning" {"Warning"} "Error" {"Error"} } - - Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message + + Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message -ErrorAction Stop + } + catch { + # Event logging is best-effort; the file log above is the system of record. } -} \ No newline at end of file +} diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 9b33dbc..2aaab2d 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '2.3' + ModuleVersion = '2.4' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -34,7 +34,6 @@ # -- Intune Packaging Tools -- 'New-IntunePackage', # The CLI Packager (with Detect/Uninstall generation) 'New-IntunePackageUI', # The GUI Packager - 'Show-IntunePackager', # The Wrapper for the MS Utility # -- Utilities -- 'Write-PackagerLog' diff --git a/TecharyGet.psm1 b/TecharyGet.psm1 index 4285024..fc6645f 100644 --- a/TecharyGet.psm1 +++ b/TecharyGet.psm1 @@ -1,4 +1,25 @@ -$Root = Split-Path $MyInvocation.MyCommand.Path -Get-ChildItem -Path "$Root\Private\*.ps1" | ForEach-Object { . $_.FullName } -Get-ChildItem -Path "$Root\Public\*.ps1" | ForEach-Object { . $_.FullName } -Export-ModuleMember -Function (Get-ChildItem -Path "$Root\Public\*.ps1").BaseName +$Root = $PSScriptRoot +if (-not $Root) { $Root = Split-Path -Parent $MyInvocation.MyCommand.Path } + +# Dot-source Private first: Public functions depend on those helpers. +# A failure here is fatal - a half-loaded module is worse than no module, +# because the caller gets "command not found" instead of the real reason. +foreach ($Scope in 'Private', 'Public') { + $Dir = Join-Path $Root $Scope + if (-not (Test-Path $Dir)) { continue } + + foreach ($File in (Get-ChildItem -Path $Dir -Filter '*.ps1' -File)) { + try { + . $File.FullName + } + catch { + throw "TecharyGet: failed to load '$Scope\$($File.Name)': $($_.Exception.Message)" + } + } +} + +$PublicDir = Join-Path $Root 'Public' +if (Test-Path $PublicDir) { + $ToExport = @((Get-ChildItem -Path $PublicDir -Filter '*.ps1' -File).BaseName) + if ($ToExport.Count -gt 0) { Export-ModuleMember -Function $ToExport } +} From 62dd2d9b73313555a82e45e1784d27ca5fd55200 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 11:59:23 +0100 Subject: [PATCH 40/50] Publish a prebuilt manifest index to remove the API from the install path Follow-up to the caching work: takes the steady state to zero api.github.com calls rather than merely fewer. A nightly Action resolves every package in Index/Catalog.json once, in CI, where GITHUB_TOKEN gives a 5000/hour allowance, and publishes the result as a single Manifests.json. Endpoints read that one file from raw.githubusercontent.com, which is CDN-backed and carries no API rate limit, so an install resolves without spending any of the site's 60/hour public-IP allowance. The index is force-pushed to an orphan manifest-index branch. That keeps exactly one commit on it, so a nightly refresh never grows the repository and never touches code history. Resolution order in Get-GitHubInstaller is now: local per-package cache -> prebuilt index -> live API -> stale cache Every layer is optional. A missing, stale, unreachable or malformed index returns null and falls through to the live API exactly as before, so this cannot make installs worse than they are today. Adding a package to the index is a one-line edit to Index/Catalog.json. The seed list holds the three IDs that already carry argument overrides in the code plus common business applications; edit freely. Verified: builder resolves 7zip.7zip v26.03 and Notepad++.Notepad++ v8.9.8 against live manifests and emits a valid index; client returns the correct entry on a hit, and null on an unknown ID, an unindexed architecture and a completely absent index, degrading to the live API in each case. --- .gitattributes | 6 ++ .github/workflows/build-manifest-index.yml | 54 +++++++++++++ .gitignore | 2 + Build/Build-ManifestIndex.ps1 | 91 ++++++++++++++++++++++ Index/Catalog.json | 17 ++++ Private/Get-ManifestIndex.ps1 | 86 ++++++++++++++++++++ Public/Get-GitHubInstaller.ps1 | 15 +++- 7 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/build-manifest-index.yml create mode 100644 .gitignore create mode 100644 Build/Build-ManifestIndex.ps1 create mode 100644 Index/Catalog.json create mode 100644 Private/Get-ManifestIndex.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3dbbea2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Workflow files must stay LF. A CRLF `run:` block reaches bash on the runner +# with a literal CR on every line, which breaks the script. +# Deliberately NOT declaring `* text=auto`: the existing blobs in this repo are +# committed CRLF, so that would queue a repo-wide renormalisation diff. +*.yml -text +*.yaml -text diff --git a/.github/workflows/build-manifest-index.yml b/.github/workflows/build-manifest-index.yml new file mode 100644 index 0000000..9f12f25 --- /dev/null +++ b/.github/workflows/build-manifest-index.yml @@ -0,0 +1,54 @@ +name: Build manifest index + +on: + schedule: + # Nightly. NOTE: GitHub only runs scheduled workflows from the repository's + # DEFAULT branch, so this fires once the workflow exists on that branch. + - cron: '17 3 * * *' + workflow_dispatch: + push: + paths: + - 'Index/Catalog.json' + - 'Build/Build-ManifestIndex.ps1' + - 'Private/Resolve-GitHubManifest.ps1' + - '.github/workflows/build-manifest-index.yml' + +permissions: + contents: write + +concurrency: + group: manifest-index + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve manifests + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./Build/Build-ManifestIndex.ps1 -CatalogPath Index/Catalog.json -OutputPath Index/Manifests.json + + - name: Publish to the manifest-index branch + run: | + set -euo pipefail + cp Index/Manifests.json "$RUNNER_TEMP/Manifests.json" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Orphan + force-push keeps exactly one commit on the branch, so a + # nightly refresh never grows the repository or touches code history. + git checkout --orphan manifest-index + git rm -rf . -q + cp "$RUNNER_TEMP/Manifests.json" Manifests.json + git add Manifests.json + git commit -q -m "Refresh manifest index ($(date -u +%Y-%m-%dT%H:%M:%SZ))" + git push -f origin manifest-index + + echo "Published to https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/manifest-index/Manifests.json" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0b7058 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Built in CI and published to the manifest-index branch, never committed here. +Index/Manifests.json diff --git a/Build/Build-ManifestIndex.ps1 b/Build/Build-ManifestIndex.ps1 new file mode 100644 index 0000000..68fc61b --- /dev/null +++ b/Build/Build-ManifestIndex.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Pre-resolves winget-pkgs manifests into a single JSON index. + +.DESCRIPTION + Runs in CI, not on endpoints. Resolving a package costs two + api.github.com calls; doing it once here and publishing the result means + endpoints read one file from raw.githubusercontent.com instead, which is + CDN-backed and not subject to the API rate limit. + + A package that fails to resolve is reported and skipped. The build only + fails if every package failed, so one delisted app cannot stop the + nightly refresh. +#> +[CmdletBinding()] +param( + [string]$CatalogPath = 'Index/Catalog.json', + [string]$OutputPath = 'Index/Manifests.json', + [string[]]$Architectures = @('x64', 'arm64'), + [string]$GitHubToken = $env:GITHUB_TOKEN +) + +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot '..\Private\Resolve-GitHubManifest.ps1') + +$Headers = @{ 'User-Agent' = 'TecharyGet-IndexBuilder' } +if ($GitHubToken) { + $Headers['Authorization'] = "token $GitHubToken" + Write-Host "Authenticated to the GitHub API (5000 requests/hour)." +} else { + Write-Warning "No token supplied. Falling back to 60 requests/hour, which will not cover a full catalogue." +} + +$Catalog = Get-Content -Path $CatalogPath -Raw | ConvertFrom-Json +$Ids = @($Catalog.Packages | Sort-Object) +Write-Host "Catalogue contains $($Ids.Count) package(s); resolving $($Architectures -join ', ')." + +$Packages = [ordered]@{} +$Ok = 0 +$Failed = New-Object System.Collections.Generic.List[string] + +foreach ($Id in $Ids) { + $PerArch = [ordered]@{} + + foreach ($Arch in $Architectures) { + try { + $Meta = Resolve-GitHubManifest -Id $Id -SysArch $Arch -Headers $Headers + $PerArch[$Arch] = [ordered]@{ + Version = $Meta.Version + Url = $Meta.Url + SilentArgs = $Meta.SilentArgs + InstallerType = $Meta.InstallerType + ProductCode = $Meta.ProductCode + } + Write-Host (" ok {0,-34} {1,-6} v{2}" -f $Id, $Arch, $Meta.Version) + } + catch { + # Most packages genuinely have no arm64 installer. Only an x64 + # failure is worth counting as a real failure. + $Level = if ($Arch -eq 'x64') { 'warn ' } else { 'skip ' } + Write-Host (" {0} {1,-34} {2,-6} {3}" -f $Level, $Id, $Arch, $_.Exception.Message) + } + } + + if ($PerArch.Count -gt 0) { + $Packages[$Id] = $PerArch + $Ok++ + } else { + $Failed.Add($Id) + } +} + +if ($Ok -eq 0) { + throw "Every package in the catalogue failed to resolve. Refusing to publish an empty index." +} + +$Index = [ordered]@{ + Generated = (Get-Date).ToUniversalTime().ToString('o') + Source = 'microsoft/winget-pkgs' + PackageCount = $Ok + Packages = $Packages +} + +$Dir = Split-Path $OutputPath -Parent +if ($Dir -and -not (Test-Path $Dir)) { New-Item -ItemType Directory -Path $Dir -Force | Out-Null } +$Index | ConvertTo-Json -Depth 6 | Set-Content -Path $OutputPath -Encoding UTF8 + +Write-Host "" +Write-Host "Wrote $OutputPath with $Ok package(s)." +if ($Failed.Count -gt 0) { Write-Warning "Unresolved: $($Failed -join ', ')" } diff --git a/Index/Catalog.json b/Index/Catalog.json new file mode 100644 index 0000000..efe1d6b --- /dev/null +++ b/Index/Catalog.json @@ -0,0 +1,17 @@ +{ + "_comment": "winget package IDs pre-resolved nightly into Manifests.json. Adding an ID here is the only step needed to take it off the live GitHub API path. Seed list - edit freely.", + "Packages": [ + "7zip.7zip", + "8x8.Work", + "Adobe.Acrobat.Reader.64-bit", + "Dell.CommandUpdate", + "Google.Chrome", + "Microsoft.PowerToys", + "Microsoft.VisualStudioCode", + "Mozilla.Firefox", + "Notepad++.Notepad++", + "SublimeHQ.SublimeText.4", + "VideoLAN.VLC", + "Zoom.Zoom" + ] +} diff --git a/Private/Get-ManifestIndex.ps1 b/Private/Get-ManifestIndex.ps1 new file mode 100644 index 0000000..99089ec --- /dev/null +++ b/Private/Get-ManifestIndex.ps1 @@ -0,0 +1,86 @@ +function Get-ManifestIndexUrl { + # Published by .github/workflows/build-manifest-index.yml to a dedicated + # orphan branch, so a nightly refresh never touches code history. + # raw.githubusercontent.com is CDN-backed and is NOT subject to the + # api.github.com rate limit, which is the entire point of the index. + return "https://raw.githubusercontent.com/Techary/TecharyGet/manifest-index/Manifests.json" +} + +function Get-ManifestIndex { + [CmdletBinding()] + param( + [int]$CacheHours = 12, + [switch]$Force + ) + + $CacheDir = "$env:ProgramData\TecharyGet" + $CachePath = Join-Path $CacheDir 'ManifestIndex.json' + + try { + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } + + $NeedUpdate = $true + if (-not $Force -and (Test-Path $CachePath)) { + $Age = (Get-Date) - (Get-Item $CachePath).LastWriteTime + if ($Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } + } + + if ($NeedUpdate) { + try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + + # Stage and parse before promoting, so a captive portal or proxy + # page returning HTTP 200 with HTML cannot poison the cache. + $StagePath = "$CachePath.tmp" + Invoke-WebRequest -Uri (Get-ManifestIndexUrl) -OutFile $StagePath -UseBasicParsing -ErrorAction Stop + + $Parsed = Get-Content -Path $StagePath -Raw | ConvertFrom-Json + if (-not $Parsed.Packages) { throw "Index downloaded but contains no Packages block." } + + Move-Item -Path $StagePath -Destination $CachePath -Force -ErrorAction Stop + Write-PackagerLog -Message "Manifest index refreshed ($($Parsed.PackageCount) packages, generated $($Parsed.Generated))." + } + } + catch { + # An index miss is not an error: the live API path still works. + Write-PackagerLog -Message "Could not refresh manifest index ($($_.Exception.Message)). Using local copy if present." -Severity Warning + Remove-Item "$CachePath.tmp" -Force -ErrorAction SilentlyContinue + } + + if (-not (Test-Path $CachePath)) { return $null } + + try { return (Get-Content -Path $CachePath -Raw | ConvertFrom-Json) } + catch { + Write-PackagerLog -Message "Manifest index cache is unreadable: $($_.Exception.Message)" -Severity Warning + return $null + } +} + +function Get-IndexedManifest { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Id, + [Parameter(Mandatory=$true)][string]$SysArch + ) + + $Index = Get-ManifestIndex + if (-not $Index -or -not $Index.Packages) { return $null } + + $Entry = $Index.Packages.$Id + if (-not $Entry) { return $null } + + $ForArch = $Entry.$SysArch + if (-not $ForArch -or -not $ForArch.Url) { return $null } + + # Shaped identically to Resolve-GitHubManifest so callers can use either. + return [PSCustomObject]@{ + Id = $Id + Version = $ForArch.Version + Arch = $SysArch + Url = $ForArch.Url + SilentArgs = $ForArch.SilentArgs + InstallerType = $ForArch.InstallerType + ProductCode = $ForArch.ProductCode + ResolvedUtc = $Index.Generated + Source = 'index' + } +} diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 index 83c10a6..85f424f 100644 --- a/Public/Get-GitHubInstaller.ps1 +++ b/Public/Get-GitHubInstaller.ps1 @@ -10,8 +10,9 @@ function Get-GitHubInstaller { # without the token appearing in a command line or an RMM job log. [string]$GitHubToken = $env:TECHARYGET_GITHUB_TOKEN, - # Resolved manifests are cached on disk. Repeat and retry installs of - # the same app then cost no API calls at all. + # Resolved manifests are cached on disk, and the prebuilt index covers + # the catalogue centrally. Repeat and retry installs of the same app + # then cost no API calls at all. [int]$CacheHours = 24, [switch]$NoCache ) @@ -53,6 +54,16 @@ function Get-GitHubInstaller { } } + # Prebuilt index: one CDN file covering the whole catalogue, refreshed + # nightly in CI. Costs no api.github.com allowance at all. + if (-not $Meta -and -not $NoCache) { + $Indexed = Get-IndexedManifest -Id $Id -SysArch $SysArch + if ($Indexed) { + Write-PackagerLog -Message "Resolved $Id v$($Indexed.Version) from the prebuilt index. No API call needed." + $Meta = $Indexed + } + } + if (-not $Meta) { try { $Meta = Resolve-GitHubManifest -Id $Id -SysArch $SysArch -Headers $Headers From 4eb819c4d856c4efcf07c5ab83542aa80d88c83c Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:02:26 +0100 Subject: [PATCH 41/50] Make MSIX removal work under SYSTEM and stop deprovisioned apps returning Two problems with the Modern Apps branch of Uninstall-TecharyApp, both of which only show up in the context the module is actually driven from. Get-AppxPackage without -AllUsers returns only the calling account's packages. SYSTEM has essentially none, so an uninstall pushed from an RMM found nothing and logged "not found on this system" for an app that was plainly installed. It now enumerates and removes with -AllUsers when elevated, and says so when it is not, so the log states the scope that actually applied rather than implying a machine-wide removal. Removing the per-user registrations also left the provisioned package in place, and a provisioned package is what seeds new user profiles. The app therefore reappeared for the next user who signed in. Provisioned packages matching the name are now removed as well. Neither path is all-or-nothing: -AllUsers is unsupported on some builds, so a failure there retries per-user rather than reporting an outright failure, and provisioning enumeration failing does not stop the package removal. Verified non-elevated: correct package found, WhatIf reports the honest scope, and a package that is not installed still reports cleanly. The elevated -AllUsers and deprovisioning paths are guarded by try/catch with per-user fallback but were not exercised from this session. --- Public/Uninstall-TecharyApp.ps1 | 81 +++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index e47b9f5..bf851b2 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -27,26 +27,91 @@ function Uninstall-TecharyApp { # 2. IF NOT IN REGISTRY, CHECK MSIX (Modern Apps) if (-not $App) { Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." - $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue - if ($MsixResults) { - # FIX: Handle cases where multiple apps match (Array vs Single Object) + # SYSTEM has essentially no packages of its own, so a plain + # Get-AppxPackage run from an RMM found nothing and reported the app as + # absent. -AllUsers is what makes this work in the context the module is + # actually driven from. It needs elevation, so fall back without it. + $IsElevated = $false + try { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $IsElevated = (New-Object Security.Principal.WindowsPrincipal($Identity)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch {} + + $MsixResults = @() + if ($IsElevated) { + try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop) } + catch { + Write-PackagerLog -Message "Could not enumerate packages for all users ($($_.Exception.Message)). Falling back to the current user." -Severity Warning + $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) + } + } else { + Write-PackagerLog -Message "Not elevated, so only the current user's packages are visible." -Severity Warning + $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) + } + + # A provisioned package is what seeds new user profiles. Leaving it in + # place meant a removed app reappeared for the next user who signed in. + $Provisioned = @() + if ($IsElevated) { + try { + $Provisioned = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | + Where-Object { $_.DisplayName -like "*$Name*" }) + } catch { + Write-PackagerLog -Message "Could not enumerate provisioned packages: $($_.Exception.Message)" -Severity Warning + } + } + + if ($MsixResults.Count -gt 0 -or $Provisioned.Count -gt 0) { + # Handle cases where multiple apps match (Array vs Single Object) foreach ($Package in $MsixResults) { - Write-PackagerLog -Message "Found Modern App: $($Package.Name)" + Write-PackagerLog -Message "Found Modern App: $($Package.Name) ($($Package.PackageFullName))" + + if ($WhatIf) { + $Scope = if ($IsElevated) { "for all users" } else { "for the current user only" } + Write-Host "[WhatIf] Would remove $Scope`: $($Package.PackageFullName)" -ForegroundColor Yellow + continue + } + try { + if ($IsElevated) { + Remove-AppxPackage -Package $Package.PackageFullName -AllUsers -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for all users." + } else { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + } + } + catch { + # -AllUsers is unsupported on some builds. A per-user removal + # still beats reporting an outright failure. + Write-PackagerLog -Message "All-users removal failed for $($Package.Name): $($_.Exception.Message). Retrying for the current user." -Severity Warning + try { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + } + catch { + Write-PackagerLog -Message "Failed to remove $($Package.Name): $($_.Exception.Message)" -Severity Error + } + } + } + + foreach ($Prov in $Provisioned) { if ($WhatIf) { - Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow + Write-Host "[WhatIf] Would deprovision: $($Prov.PackageName)" -ForegroundColor Yellow continue } try { - Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name)" + Remove-AppxProvisionedPackage -Online -PackageName $Prov.PackageName -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "Deprovisioned $($Prov.DisplayName), so it will not return for new users." } catch { - Write-PackagerLog -Message "Failed to remove $($Package.Name): $_" -Severity Error + Write-PackagerLog -Message "Failed to deprovision $($Prov.DisplayName): $($_.Exception.Message)" -Severity Error } } + return } From 0f68186f97ffefaed9a93c9ff8b83dfac54a7d93 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:06:19 +0100 Subject: [PATCH 42/50] Stop leaving Appx sideloading policy permanently enabled Installing any MSIX through the module wrote AllowAllTrustedApps=1 to both HKLM\SOFTWARE\Policies\Microsoft\Windows\Appx and AppModelUnlock, and never put them back. Every machine that ever installed an MSIX was left with sideloading permanently enabled, including machines where the policy had previously been explicitly disabled. It also did this unconditionally, before finding out whether it was needed. A correctly signed package usually provisions with no policy change at all. Provisioning is now attempted as the machine is configured. Only if that is refused is the policy relaxed, and it is restored in a finally block that runs on the success path, the per-user fallback path, and the throw. Restoration is exact rather than "set it back to 0": the prior state of each value is recorded first, and the restore puts back the previous value if there was one, removes just the value if the key existed without it, or removes the key entirely if we created it. A pre-existing AllowAllTrustedApps=0 therefore comes back as 0, not as a deleted value. Push-SideloadPolicy deliberately never throws. If it did, the state describing what had already been changed would be lost with it, and the caller's finally block would have nothing to restore from, leaving the policy relaxed. Failures are reported through the returned object instead. Pop-SideloadPolicy indexes its list directly rather than wrapping it in @(). On PowerShell 7.6 / .NET 10, @() over a List[object] throws "Argument types do not match", which aborted the restore and left the policy relaxed. That was caught by the round-trip test below and is the exact failure this change exists to prevent. Verified against a scratch HKCU key across all three prior states: key absent, key present without the value, and key present with AllowAllTrustedApps=0. All three read 1 while relaxed and are byte-identical to their original state afterwards, with an unrelated value in the same key left untouched. --- Private/SideloadPolicy.ps1 | 110 ++++++++++++++++++++++++++++++++++ Public/Install-AppPackage.ps1 | 42 +++++++++---- 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 Private/SideloadPolicy.ps1 diff --git a/Private/SideloadPolicy.ps1 b/Private/SideloadPolicy.ps1 new file mode 100644 index 0000000..f6c991e --- /dev/null +++ b/Private/SideloadPolicy.ps1 @@ -0,0 +1,110 @@ +function Push-SideloadPolicy { + <# + .SYNOPSIS + Relaxes the Appx sideloading policy and returns enough state to undo it. + + .DESCRIPTION + Records, for each value it touches, whether the key already existed, + whether the value already existed, and what the value was. Pass the + result to Pop-SideloadPolicy to put the machine back as it was found. + + This deliberately never throws. If it did, the state describing what + had already been changed would be lost with it and the caller's finally + block would have nothing to restore from, leaving the policy relaxed. + Failures are recorded and reported through the returned object instead. + + Paths are parameters so the round trip can be tested against a scratch + key without touching real machine policy. + #> + [CmdletBinding()] + param( + [string[]]$Paths = @( + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + ), + [string]$ValueName = "AllowAllTrustedApps" + ) + + $Changed = New-Object System.Collections.Generic.List[object] + $Ok = $true + $Errors = New-Object System.Collections.Generic.List[string] + + foreach ($Path in $Paths) { + $KeyExisted = Test-Path $Path + $ValueExisted = $false + $PriorValue = $null + + if ($KeyExisted) { + $Existing = Get-ItemProperty -Path $Path -Name $ValueName -ErrorAction SilentlyContinue + if ($null -ne $Existing) { + $ValueExisted = $true + $PriorValue = $Existing.$ValueName + } + } + + try { + if (-not $KeyExisted) { New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } + + # Record before writing, so anything we manage to change is undoable + # even if a later step fails. + $Changed.Add([PSCustomObject]@{ + Path = $Path + ValueName = $ValueName + KeyExisted = $KeyExisted + ValueExisted = $ValueExisted + PriorValue = $PriorValue + }) + + New-ItemProperty -Path $Path -Name $ValueName -Value 1 -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + } + catch { + $Ok = $false + $Errors.Add("$Path : $($_.Exception.Message)") + } + } + + return [PSCustomObject]@{ + Changed = $Changed + Success = $Ok + Errors = $Errors + } +} + +function Pop-SideloadPolicy { + <# + .SYNOPSIS + Restores whatever Push-SideloadPolicy changed. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $State + ) + + if (-not $State -or -not $State.Changed -or $State.Changed.Count -eq 0) { return } + + # Indexed directly rather than wrapped in @(). On PowerShell 7.6 / .NET 10, + # @() over a List[object] throws "Argument types do not match", which would + # abort the restore and leave sideloading policy relaxed - the exact failure + # this function exists to prevent. + for ($i = $State.Changed.Count - 1; $i -ge 0; $i--) { + $Item = $State.Changed[$i] + + try { + if ($Item.ValueExisted) { + New-ItemProperty -Path $Item.Path -Name $Item.ValueName -Value $Item.PriorValue -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + } + elseif ($Item.KeyExisted) { + Remove-ItemProperty -Path $Item.Path -Name $Item.ValueName -Force -ErrorAction Stop + } + else { + # We created the key, so take the whole thing back out. + Remove-Item -Path $Item.Path -Force -Recurse -ErrorAction Stop + } + } + catch { + Write-PackagerLog -Message "Could not restore sideload policy at $($Item.Path): $($_.Exception.Message)" -Severity Warning + } + } +} diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 index 8fbf4c8..9bdd35e 100644 --- a/Public/Install-AppPackage.ps1 +++ b/Public/Install-AppPackage.ps1 @@ -31,21 +31,43 @@ function Install-AppPackage { } { $_ -in ".msix", ".appx", ".msixbundle", ".appxbundle" } { Write-PackagerLog -Message "Detected Modern App. Sideloading..." + + # Try provisioning as the machine is configured, before changing + # anything. A correctly signed package normally needs no policy + # change at all, and the previous code relaxed sideloading policy + # unconditionally on every MSIX install and never put it back, + # permanently weakening every machine the module touched. + $Provisioned = $false try { - $PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx" - if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } - New-ItemProperty -Path $PolicyPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - - $DevPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - if (-not (Test-Path $DevPath)) { New-Item -Path $DevPath -Force | Out-Null } - New-ItemProperty -Path $DevPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null Write-PackagerLog -Message "MSIX Provisioned Successfully." + $Provisioned = $true } catch { - Write-PackagerLog -Message "Provisioning Failed ($($_)). Trying Per-User..." -Severity Warning - try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + Write-PackagerLog -Message "Provisioning refused under current policy ($($_.Exception.Message)). Relaxing sideloading policy temporarily." -Severity Warning + } + + if (-not $Provisioned) { + $PolicyState = $null + try { + $PolicyState = Push-SideloadPolicy + if (-not $PolicyState.Success) { + Write-PackagerLog -Message "Could not fully relax sideloading policy: $($PolicyState.Errors -join '; ')" -Severity Warning + } + + Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "MSIX Provisioned Successfully." + $Provisioned = $true + } + catch { + Write-PackagerLog -Message "Provisioning Failed ($($_.Exception.Message)). Trying Per-User..." -Severity Warning + try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + } + finally { + # Always, including on the throw above. + Pop-SideloadPolicy -State $PolicyState + Write-PackagerLog -Message "Sideloading policy restored to its previous state." + } } return } From 0f0f7201d9c562977926f4519a75b19eafa79139 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:12:07 +0100 Subject: [PATCH 43/50] Detect applications by ProductCode instead of substring matching Test-TecharyApp matched only on DisplayName -like "*$Name*", which fails in both directions. False negative: it could not detect anything by winget package ID, which is the identifier Install-TecharyApp takes. No ARP DisplayName contains the string "7zip.7zip", so Test-TecharyApp -Name 7zip.7zip returned False on a machine with 7-Zip installed. False positive: "Teams" matches "Microsoft Teams Meeting Add-in for Microsoft Office", so it reports Teams as installed on a machine with no Teams desktop app. Detection now works most-precise-first. winget records the ARP subkey name in ProductCode, an MSI product GUID or a plain name such as "7-Zip", so with the manifest index available this becomes a direct key lookup and is definitive. Failing that it tries an exact DisplayName, then the previous substring behaviour, then MSIX. The substring tier is kept deliberately. Removing it would flip detections from True to False across the estate and trigger reinstalls. It is now reported through MatchedBy so an imprecise match is visible rather than indistinguishable from an exact one. Makes no network calls. Get-ManifestIndex and Get-CustomApp take -NoRefresh, and detection uses it, because this runs on a schedule on every endpoint. Names are escaped before wildcard comparison. A name containing [ or ] was previously treated as a wildcard pattern and silently matched nothing. MSIX enumeration uses -AllUsers when elevated, for the same reason as the uninstall path: SYSTEM sees almost none of its own packages. Adds -Detailed, returning what matched and the installed version. The default return stays a boolean, so existing callers are unaffected. Verified against the BETA implementation on the same machine: 7zip.7zip goes False to True via ProductCode, and 7-Zip, Teams and WindowsCalculator are unchanged. No case flips to False. --- Private/Get-CustomApp.ps1 | 8 +- Private/Get-ManifestIndex.ps1 | 10 ++- Public/Test-TecharyApp.ps1 | 164 ++++++++++++++++++++++++++++------ 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/Private/Get-CustomApp.ps1 b/Private/Get-CustomApp.ps1 index 28ca71e..d528c3e 100644 --- a/Private/Get-CustomApp.ps1 +++ b/Private/Get-CustomApp.ps1 @@ -1,6 +1,10 @@ function Get-CustomApp { param ( - [string]$Id + [string]$Id, + + # Detection callers pass this so a scheduled check never makes a + # network call; it reads whatever copy is already on disk. + [switch]$NoRefresh ) # --- 1. CLOUD SOURCE --- @@ -22,7 +26,7 @@ function Get-CustomApp { # Logic: Only download if the cache doesn't exist OR it's older than 60 minutes. # This prevents spamming GitHub every time you run a command. - $NeedUpdate = $true + $NeedUpdate = -not $NoRefresh if (Test-Path $CachePath) { $LastWrite = (Get-Item $CachePath).LastWriteTime if ((Get-Date) -lt $LastWrite.AddMinutes(60)) { $NeedUpdate = $false } diff --git a/Private/Get-ManifestIndex.ps1 b/Private/Get-ManifestIndex.ps1 index 99089ec..5a747fc 100644 --- a/Private/Get-ManifestIndex.ps1 +++ b/Private/Get-ManifestIndex.ps1 @@ -10,6 +10,7 @@ function Get-ManifestIndex { [CmdletBinding()] param( [int]$CacheHours = 12, + [switch]$NoRefresh, [switch]$Force ) @@ -19,7 +20,9 @@ function Get-ManifestIndex { try { if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } - $NeedUpdate = $true + # Detection runs on a schedule on every endpoint, so it must never make + # a network call. -NoRefresh reads whatever is already cached. + $NeedUpdate = -not $NoRefresh if (-not $Force -and (Test-Path $CachePath)) { $Age = (Get-Date) - (Get-Item $CachePath).LastWriteTime if ($Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } @@ -59,10 +62,11 @@ function Get-IndexedManifest { [CmdletBinding()] param( [Parameter(Mandatory=$true)][string]$Id, - [Parameter(Mandatory=$true)][string]$SysArch + [Parameter(Mandatory=$true)][string]$SysArch, + [switch]$NoRefresh ) - $Index = Get-ManifestIndex + $Index = Get-ManifestIndex -NoRefresh:$NoRefresh if (-not $Index -or -not $Index.Packages) { return $null } $Entry = $Index.Packages.$Id diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 82ebb9e..8f81a88 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -1,42 +1,150 @@ function Test-TecharyApp { + <# + .SYNOPSIS + Reports whether an application is installed. + + .DESCRIPTION + Detection order, most precise first: + + 1. ProductCode from the manifest index or cache. winget records the + ARP subkey name here (for example "7-Zip", or an MSI product GUID), + so this is a direct key lookup and is definitive. + 2. Exact DisplayName, from the custom catalogue or from -Name itself. + 3. Substring DisplayName match, which is what this function used to do + exclusively. Retained so existing callers do not start returning + false, but reported as imprecise because it produces false + positives: "Teams" matches "Microsoft Teams Meeting Add-in for + Microsoft Office" on a machine with no Teams desktop app. + 4. MSIX package name. + + Makes no network calls. The manifest index and custom catalogue are + read from their existing on-disk caches only, because this runs on a + schedule on every endpoint. + + .PARAMETER Name + A winget package ID ("7zip.7zip"), a custom catalogue ID ("MyDPD"), + or a display name. + + .PARAMETER Detailed + Return an object describing what matched instead of a boolean. + #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [string]$Name # App ID (e.g. "MyDPD") or Display Name + [string]$Name, + + [switch]$Detailed ) - # 1. RESOLVE ID -> DISPLAY NAME - # Check if this ID exists in your Custom Catalog with a specific DisplayName mapping - $CustomApp = Get-CustomApp -Id $Name - if ($CustomApp -and $CustomApp.DisplayName) { - $Name = $CustomApp.DisplayName + $Hives = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall" + ) + + function New-Result { + param($Installed, $MatchedBy, $DisplayName, $Version, $Key) + [PSCustomObject]@{ + Name = $Name + Installed = [bool]$Installed + MatchedBy = $MatchedBy + DisplayName = $DisplayName + InstalledVersion = $Version + RegistryKey = $Key + } } - # 2. SEARCH REGISTRY (Classic Apps) - $Paths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # --- 1. PRODUCT CODE (definitive) --------------------------------- + $ProductCode = $null + try { + $Indexed = Get-IndexedManifest -Id $Name -SysArch $SysArch -NoRefresh + if ($Indexed) { $ProductCode = $Indexed.ProductCode } + } catch {} + + if (-not $ProductCode) { + $CachedManifest = Join-Path $env:ProgramData ("TecharyGet\ManifestCache\" + ($Name -replace '[\\/:*?"<>|]', '_') + ".json") + if (Test-Path $CachedManifest) { + try { $ProductCode = (Get-Content $CachedManifest -Raw | ConvertFrom-Json).ProductCode } catch {} + } + } + + if ($ProductCode) { + foreach ($Hive in $Hives) { + $Key = Join-Path $Hive $ProductCode + if (Test-Path $Key) { + $Item = Get-ItemProperty -Path $Key -ErrorAction SilentlyContinue + Write-Verbose "Matched on ProductCode '$ProductCode' at $Key" + $R = New-Result $true 'ProductCode' $Item.DisplayName $Item.DisplayVersion $Key + if ($Detailed) { return $R } else { return $true } + } + } + } + + # --- 2. EXACT DISPLAY NAME ---------------------------------------- + # A custom catalogue entry carries the real ARP DisplayName for its ID. + $Candidates = New-Object System.Collections.Generic.List[string] + $Candidates.Add($Name) + try { + $CustomApp = Get-CustomApp -Id $Name -NoRefresh + if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } + } catch {} + + $AllArp = foreach ($Hive in $Hives) { + Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue + } - foreach ($Path in $Paths) { - $Match = Get-ItemProperty $Path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "*$Name*" } | - Select-Object -First 1 - - if ($Match) { - Write-Verbose "Found Registry Match: $($Match.DisplayName)" - return $true + foreach ($Candidate in $Candidates) { + $Exact = $AllArp | Where-Object { $_.DisplayName -eq $Candidate } | Select-Object -First 1 + if ($Exact) { + Write-Verbose "Matched exactly on DisplayName '$Candidate'" + $R = New-Result $true 'ExactName' $Exact.DisplayName $Exact.DisplayVersion $Exact.PSPath + if ($Detailed) { return $R } else { return $true } } } - # 3. SEARCH MSIX (Modern Apps) - $Msix = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($Msix) { - Write-Verbose "Found MSIX: $($Msix.Name)" - return $true + # --- 3. SUBSTRING (imprecise, kept for compatibility) ------------- + foreach ($Candidate in $Candidates) { + # Escaped: an unescaped name containing [ or ] is a wildcard pattern, + # which previously made the comparison silently match nothing. + $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" + $Loose = $AllArp | Where-Object { $_.DisplayName -like $Pattern } | Select-Object -First 1 + if ($Loose) { + Write-Verbose "Matched '$Candidate' only as a substring of '$($Loose.DisplayName)'. This is imprecise; add the package to the manifest index for an exact ProductCode match." + $R = New-Result $true 'Substring' $Loose.DisplayName $Loose.DisplayVersion $Loose.PSPath + if ($Detailed) { return $R } else { return $true } + } + } + + # --- 4. MSIX ------------------------------------------------------ + # SYSTEM sees almost no packages of its own, so enumerate for all users + # when we are able to. + $IsElevated = $false + try { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $IsElevated = (New-Object Security.Principal.WindowsPrincipal($Identity)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch {} + + foreach ($Candidate in $Candidates) { + $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" + $Msix = $null + try { + if ($IsElevated) { $Msix = Get-AppxPackage -AllUsers -Name $Pattern -ErrorAction SilentlyContinue | Select-Object -First 1 } + if (-not $Msix) { $Msix = Get-AppxPackage -Name $Pattern -ErrorAction SilentlyContinue | Select-Object -First 1 } + } catch {} + + if ($Msix) { + Write-Verbose "Matched MSIX package '$($Msix.Name)'" + $R = New-Result $true 'Msix' $Msix.Name $Msix.Version $Msix.PackageFullName + if ($Detailed) { return $R } else { return $true } + } } - # 4. NOT FOUND - return $false -} \ No newline at end of file + # --- 5. NOT FOUND ------------------------------------------------- + $R = New-Result $false 'None' $null $null $null + if ($Detailed) { return $R } else { return $false } +} From 465d80ed9c014ed41b5491863bfbb2fce8d8b5fd Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:26:14 +0100 Subject: [PATCH 44/50] Confirm MSIX removal instead of inferring it from the absence of an error Validated on a real machine running as SYSTEM. Remove-AppxPackage -AllUsers works, but two behaviours make a naive reading of it unreliable. Removal is asynchronous. The package is still listed for a period after the cmdlet returns, so a check run immediately afterwards reports a failure that is not real. Confirmation now polls over a 30 second settle window, and a package still registered at the end is reported as a warning naming a pending reboot or sign-out, not asserted as a failure. Get-AppxPackage -AllUsers also lists packages that are merely Staged on the machine, so presence in that list is not evidence that anyone has the package installed. Enumeration is filtered to packages actually installed for at least one user, via PackageUserInformation, so a staged remnant is no longer treated as something to uninstall. Test-AppxInstalledForAnyUser falls back to a plain Get-AppxPackage where per-user information is unavailable, which covers the non-elevated case and older builds. Measured under SYSTEM on a real endpoint: Get-AppxPackage sees 69 packages, Get-AppxPackage -AllUsers sees 197. Verified: helper returns True for an installed package, False for one that was removed, and False for a package that does not exist. --- Private/Test-AppxInstalledForAnyUser.ps1 | 68 ++++++++++++++++++++++++ Public/Uninstall-TecharyApp.ps1 | 30 +++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 Private/Test-AppxInstalledForAnyUser.ps1 diff --git a/Private/Test-AppxInstalledForAnyUser.ps1 b/Private/Test-AppxInstalledForAnyUser.ps1 new file mode 100644 index 0000000..918df8c --- /dev/null +++ b/Private/Test-AppxInstalledForAnyUser.ps1 @@ -0,0 +1,68 @@ +function Test-AppxInstalledForAnyUser { + <# + .SYNOPSIS + True when an MSIX package is actually installed for at least one user. + + .DESCRIPTION + Get-AppxPackage -AllUsers lists packages that are merely Staged on the + machine as well as ones a user has installed, so presence in that list + is not evidence of installation. + + Removal is also asynchronous: a package stays listed for a period after + Remove-AppxPackage returns. Callers should therefore poll this over a + settle window rather than treat a single immediate result as final. + + PackageUserInformation carries the per-user InstallState, which is the + distinction that matters. Where it is unavailable - not elevated, or an + older build - this falls back to a plain Get-AppxPackage, which lists + only what is installed for the caller. + #> + [CmdletBinding()] + param( + [Parameter(ParameterSetName = 'ByObject')] + $Package, + + [Parameter(ParameterSetName = 'ByName')] + [string]$PackageFullName + ) + + if (-not $Package -and $PackageFullName) { + # Name is the segment before the first underscore of the full name, and + # filtering on it avoids enumerating every package on the machine. + $ShortName = $PackageFullName.Split('_')[0] + try { + $Package = Get-AppxPackage -AllUsers -Name $ShortName -ErrorAction Stop | + Where-Object { $_.PackageFullName -eq $PackageFullName } | + Select-Object -First 1 + } catch { } + + if (-not $Package) { + try { + $Package = Get-AppxPackage -Name $ShortName -ErrorAction SilentlyContinue | + Where-Object { $_.PackageFullName -eq $PackageFullName } | + Select-Object -First 1 + } catch { } + } + } + + # Not present at all, so certainly not installed. + if (-not $Package) { return $false } + + $Info = $null + try { $Info = $Package.PackageUserInformation } catch { } + + if ($Info) { + foreach ($User in $Info) { + if ("$($User.InstallState)" -eq 'Installed') { return $true } + } + return $false + } + + try { + $Mine = Get-AppxPackage -ErrorAction SilentlyContinue | + Where-Object { $_.PackageFullName -eq $Package.PackageFullName } + return [bool]$Mine + } catch { + return $false + } +} diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index bf851b2..7b875d2 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -41,7 +41,12 @@ function Uninstall-TecharyApp { $MsixResults = @() if ($IsElevated) { - try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop) } + # Filtered to packages actually installed for someone. -AllUsers also + # lists packages that are merely Staged on the machine, and a staged + # package remains listed after a successful removal, so an unfiltered + # list makes a completed uninstall look like it did nothing. + try { $MsixResults = @(Get-AppxPackage -AllUsers -Name "*$Name*" -ErrorAction Stop | + Where-Object { Test-AppxInstalledForAnyUser -Package $_ }) } catch { Write-PackagerLog -Message "Could not enumerate packages for all users ($($_.Exception.Message)). Falling back to the current user." -Severity Warning $MsixResults = @(Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue) @@ -77,10 +82,29 @@ function Uninstall-TecharyApp { try { if ($IsElevated) { Remove-AppxPackage -Package $Package.PackageFullName -AllUsers -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name) for all users." + $Scope = "for all users" } else { Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop - Write-PackagerLog -Message "Success: Removed $($Package.Name) for the current user." + $Scope = "for the current user" + } + + # Confirm rather than infer, but give it time to settle. + # Removal completes asynchronously: the package is still + # listed for a short period after Remove-AppxPackage returns, + # so an immediate check reports a false failure. + $Deadline = (Get-Date).AddSeconds(30) + $StillThere = $true + while ($StillThere -and (Get-Date) -lt $Deadline) { + $StillThere = Test-AppxInstalledForAnyUser -PackageFullName $Package.PackageFullName + if ($StillThere) { Start-Sleep -Seconds 2 } + } + + if ($StillThere) { + # Not asserted as a failure: removal may still be pending. + # Reported so it is visible rather than assumed successful. + Write-PackagerLog -Message "Removed $($Package.Name) $Scope, but it is still registered after 30s. Removal may be pending a reboot or sign-out." -Severity Warning + } else { + Write-PackagerLog -Message "Success: Removed $($Package.Name) $Scope, confirmed." } } catch { From 930ef746dcb108af4d53fe94aea316ba2b803431 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:59:23 +0100 Subject: [PATCH 45/50] Index every winget package for detection, not just the curated catalogue Two problems with detection as merged. The index was never fetched on an endpoint that only runs detection. -NoRefresh was implemented as "never download", but a detection-only machine installs nothing, so nothing else would ever fetch the index for it and ProductCode detection could never work there. It now means "do not re-download a copy we already have": absent means fetch, present means use what is there. Coverage was limited to the twelve packages in Index/Catalog.json. Detection of anything else fell back to name matching, which cannot resolve a winget package ID at all. The detection index is now built from Microsoft's own published winget source, which already carries the ARP product codes and MSIX package family names that detection matches on, for every package in the repository. That is one 3.5 MB CDN download in CI, with no api.github.com calls and no clone of winget-pkgs. Packages carrying neither a product code nor a package family name are omitted, because they cannot be identified this way. Detection now tries, in order: product codes and package family names from the full index, the product code from the curated index or the local manifest cache, an exact display name, a substring, then an MSIX name. Package family name matching uses -AllUsers when elevated, for the same reason as the uninstall path. The curated Index/Catalog.json stays, and still carries installer URLs and silent arguments so a common install needs no API call either. It was never an allow-list: Install-TecharyApp resolves any winget package live. --- .github/workflows/build-manifest-index.yml | 64 ++++++++++++++- .gitignore | 1 + Private/Get-DetectionIndex.ps1 | 91 ++++++++++++++++++++++ Private/Get-ManifestIndex.ps1 | 15 ++-- Public/Test-TecharyApp.ps1 | 61 ++++++++++++--- 5 files changed, 213 insertions(+), 19 deletions(-) create mode 100644 Private/Get-DetectionIndex.ps1 diff --git a/.github/workflows/build-manifest-index.yml b/.github/workflows/build-manifest-index.yml index 9f12f25..1fc54d0 100644 --- a/.github/workflows/build-manifest-index.yml +++ b/.github/workflows/build-manifest-index.yml @@ -4,6 +4,7 @@ on: schedule: # Nightly. NOTE: GitHub only runs scheduled workflows from the repository's # DEFAULT branch, so this fires once the workflow exists on that branch. + # Push and manual dispatch work from any branch. - cron: '17 3 * * *' workflow_dispatch: push: @@ -28,7 +29,62 @@ jobs: with: fetch-depth: 0 - - name: Resolve manifests + # --------------------------------------------------------------- + # Detection index: EVERY package in the winget source, not just the + # curated catalogue. Built from Microsoft's own published source + # index, which already carries the ARP product codes and MSIX package + # family names that detection matches on. One 3.5 MB CDN download, no + # api.github.com calls, no clone of winget-pkgs. + # --------------------------------------------------------------- + - name: Build detection index from the winget source + run: | + set -euo pipefail + curl -sSL --retry 3 --max-time 300 \ + -o source2.msix https://cdn.winget.microsoft.com/cache/source2.msix + ls -l source2.msix + + unzip -o -q source2.msix -d wingetsrc + DB=wingetsrc/Public/index.db + test -f "$DB" + + # Packages with neither a product code nor a package family name + # cannot be identified this way, so including them is dead weight. + # char(31) as the separator: a unit separator cannot occur inside a + # product code, whereas a pipe or comma plausibly could. + sqlite3 "$DB" -json " + SELECT p.id AS Id, + p.name AS Name, + p.latest_version AS Version, + (SELECT group_concat(productcode, char(31)) FROM productcodes2 WHERE package = p.rowid) AS ProductCodes, + (SELECT group_concat(pfn, char(31)) FROM pfns2 WHERE package = p.rowid) AS Pfns + FROM packages p + WHERE EXISTS (SELECT 1 FROM productcodes2 WHERE package = p.rowid) + OR EXISTS (SELECT 1 FROM pfns2 WHERE package = p.rowid) + ORDER BY p.id;" > packages.json + + NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) + jq --arg now "$NOW" ' + { Generated: $now, + Source: "winget source2 index", + PackageCount: length, + Packages: (map({ + key: .Id, + value: { + Name: .Name, + Version: .Version, + ProductCodes: ((.ProductCodes // "") | if . == "" then [] else split("") end), + Pfns: ((.Pfns // "") | if . == "" then [] else split("") end) + }}) | from_entries) } + ' packages.json > Index/Detection.json + + echo "detection index: $(jq -r .PackageCount Index/Detection.json) packages, $(du -h Index/Detection.json | cut -f1)" + + # --------------------------------------------------------------- + # Curated index: full install metadata (installer URL, silent args) for + # the packages in Index/Catalog.json, so a common install needs no API + # call either. Anything not listed still installs, resolved live. + # --------------------------------------------------------------- + - name: Resolve curated manifests shell: pwsh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,6 +94,7 @@ jobs: run: | set -euo pipefail cp Index/Manifests.json "$RUNNER_TEMP/Manifests.json" + cp Index/Detection.json "$RUNNER_TEMP/Detection.json" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -47,8 +104,9 @@ jobs: git checkout --orphan manifest-index git rm -rf . -q cp "$RUNNER_TEMP/Manifests.json" Manifests.json - git add Manifests.json + cp "$RUNNER_TEMP/Detection.json" Detection.json + git add Manifests.json Detection.json git commit -q -m "Refresh manifest index ($(date -u +%Y-%m-%dT%H:%M:%SZ))" git push -f origin manifest-index - echo "Published to https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/manifest-index/Manifests.json" + echo "Published to https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/manifest-index/" diff --git a/.gitignore b/.gitignore index d0b7058..4aadb73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Built in CI and published to the manifest-index branch, never committed here. Index/Manifests.json +Index/Detection.json diff --git a/Private/Get-DetectionIndex.ps1 b/Private/Get-DetectionIndex.ps1 new file mode 100644 index 0000000..327c8e9 --- /dev/null +++ b/Private/Get-DetectionIndex.ps1 @@ -0,0 +1,91 @@ +function Get-DetectionIndexUrl { + # Built nightly from Microsoft's own published winget source index, so it + # covers EVERY package in the winget repository rather than the curated + # Index/Catalog.json. Served from raw.githubusercontent.com, which is + # CDN-backed and carries no api.github.com rate limit. + return "https://raw.githubusercontent.com/Techary/TecharyGet/manifest-index/Detection.json" +} + +function Get-DetectionIndex { + <# + .SYNOPSIS + The full package-to-ARP mapping used to identify installed software. + #> + [CmdletBinding()] + param( + [int]$CacheHours = 12, + [switch]$NoRefresh, + [switch]$Force + ) + + $CacheDir = "$env:ProgramData\TecharyGet" + $CachePath = Join-Path $CacheDir 'DetectionIndex.json' + $HaveCache = Test-Path $CachePath + + try { + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } + + # -NoRefresh means "do not re-download a copy we already have", NOT + # "never download". An endpoint that only runs detection installs + # nothing, so nothing else would ever fetch this for it. + $NeedUpdate = $true + if ($HaveCache -and -not $Force) { + $Age = (Get-Date) - (Get-Item $CachePath).LastWriteTime + if ($NoRefresh -or $Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } + } + + if ($NeedUpdate) { + try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + + # Stage and parse before promoting, so a captive portal or proxy + # page returning HTTP 200 with HTML cannot poison the cache. + $StagePath = "$CachePath.tmp" + Invoke-WebRequest -Uri (Get-DetectionIndexUrl) -OutFile $StagePath -UseBasicParsing -ErrorAction Stop + + $Parsed = Get-Content -Path $StagePath -Raw | ConvertFrom-Json + if (-not $Parsed.Packages) { throw "Detection index downloaded but contains no Packages block." } + + Move-Item -Path $StagePath -Destination $CachePath -Force -ErrorAction Stop + Write-PackagerLog -Message "Detection index refreshed ($($Parsed.PackageCount) packages, generated $($Parsed.Generated))." + } + } + catch { + # A miss is not an error: detection falls back to name matching. + Write-PackagerLog -Message "Could not refresh the detection index ($($_.Exception.Message)). Using local copy if present." -Severity Warning + Remove-Item "$CachePath.tmp" -Force -ErrorAction SilentlyContinue + } + + if (-not (Test-Path $CachePath)) { return $null } + + try { return (Get-Content -Path $CachePath -Raw | ConvertFrom-Json) } + catch { + Write-PackagerLog -Message "Detection index cache is unreadable: $($_.Exception.Message)" -Severity Warning + return $null + } +} + +function Get-DetectionEntry { + <# + .SYNOPSIS + ARP product codes and MSIX package family names for one package ID. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Id, + [switch]$NoRefresh + ) + + $Index = Get-DetectionIndex -NoRefresh:$NoRefresh + if (-not $Index -or -not $Index.Packages) { return $null } + + $Entry = $Index.Packages.$Id + if (-not $Entry) { return $null } + + return [PSCustomObject]@{ + Id = $Id + Name = $Entry.Name + Version = $Entry.Version + ProductCodes = @($Entry.ProductCodes) + Pfns = @($Entry.Pfns) + } +} diff --git a/Private/Get-ManifestIndex.ps1 b/Private/Get-ManifestIndex.ps1 index 5a747fc..69ce5f6 100644 --- a/Private/Get-ManifestIndex.ps1 +++ b/Private/Get-ManifestIndex.ps1 @@ -17,15 +17,20 @@ function Get-ManifestIndex { $CacheDir = "$env:ProgramData\TecharyGet" $CachePath = Join-Path $CacheDir 'ManifestIndex.json' + $HaveCache = Test-Path $CachePath + try { if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } - # Detection runs on a schedule on every endpoint, so it must never make - # a network call. -NoRefresh reads whatever is already cached. - $NeedUpdate = -not $NoRefresh - if (-not $Force -and (Test-Path $CachePath)) { + # -NoRefresh means "do not re-download a copy we already have", NOT + # "never download". An endpoint that only ever runs detection installs + # nothing, so nothing else would ever fetch the index for it: treating + # NoRefresh as "never download" left those machines permanently without + # one, and ProductCode detection could never work there. + $NeedUpdate = $true + if ($HaveCache -and -not $Force) { $Age = (Get-Date) - (Get-Item $CachePath).LastWriteTime - if ($Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } + if ($NoRefresh -or $Age.TotalHours -lt $CacheHours) { $NeedUpdate = $false } } if ($NeedUpdate) { diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 8f81a88..323197f 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -58,32 +58,71 @@ function Test-TecharyApp { elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } else { $SysArch = "x86" } - # --- 1. PRODUCT CODE (definitive) --------------------------------- - $ProductCode = $null + # --- 1. PRODUCT CODE / PACKAGE FAMILY (definitive) ----------------- + # Three sources, most complete first: the full detection index covers + # every package in the winget repository; the curated manifest index and + # the local manifest cache cover what this machine has installed before. + $ProductCodes = New-Object System.Collections.Generic.List[string] + $Pfns = New-Object System.Collections.Generic.List[string] + + try { + $Entry = Get-DetectionEntry -Id $Name -NoRefresh + if ($Entry) { + foreach ($Code in $Entry.ProductCodes) { if ($Code) { $ProductCodes.Add($Code) } } + foreach ($Family in $Entry.Pfns) { if ($Family) { $Pfns.Add($Family) } } + Write-Verbose "Detection index: $($ProductCodes.Count) product code(s), $($Pfns.Count) package family name(s) for '$Name'" + } + } catch {} + try { $Indexed = Get-IndexedManifest -Id $Name -SysArch $SysArch -NoRefresh - if ($Indexed) { $ProductCode = $Indexed.ProductCode } + if ($Indexed -and $Indexed.ProductCode) { $ProductCodes.Add($Indexed.ProductCode) } } catch {} - if (-not $ProductCode) { - $CachedManifest = Join-Path $env:ProgramData ("TecharyGet\ManifestCache\" + ($Name -replace '[\\/:*?"<>|]', '_') + ".json") - if (Test-Path $CachedManifest) { - try { $ProductCode = (Get-Content $CachedManifest -Raw | ConvertFrom-Json).ProductCode } catch {} - } + $CachedManifest = Join-Path $env:ProgramData ("TecharyGet\ManifestCache\" + ($Name -replace '[\\/:*?"<>|]', '_') + ".json") + if (Test-Path $CachedManifest) { + try { + $Local = (Get-Content $CachedManifest -Raw | ConvertFrom-Json).ProductCode + if ($Local) { $ProductCodes.Add($Local) } + } catch {} } - if ($ProductCode) { + foreach ($Code in $ProductCodes) { foreach ($Hive in $Hives) { - $Key = Join-Path $Hive $ProductCode + $Key = Join-Path $Hive $Code if (Test-Path $Key) { $Item = Get-ItemProperty -Path $Key -ErrorAction SilentlyContinue - Write-Verbose "Matched on ProductCode '$ProductCode' at $Key" + Write-Verbose "Matched on ProductCode '$Code' at $Key" $R = New-Result $true 'ProductCode' $Item.DisplayName $Item.DisplayVersion $Key if ($Detailed) { return $R } else { return $true } } } } + if ($Pfns.Count -gt 0) { + $Elevated = $false + try { + $Ident = [Security.Principal.WindowsIdentity]::GetCurrent() + $Elevated = (New-Object Security.Principal.WindowsPrincipal($Ident)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch {} + + try { + # -AllUsers when we can: SYSTEM sees almost none of its own packages. + if ($Elevated) { $Installed = @(Get-AppxPackage -AllUsers -ErrorAction Stop) } + else { $Installed = @(Get-AppxPackage -ErrorAction SilentlyContinue) } + + foreach ($Family in $Pfns) { + $Hit = $Installed | Where-Object { $_.PackageFamilyName -eq $Family } | Select-Object -First 1 + if ($Hit) { + Write-Verbose "Matched on PackageFamilyName '$Family'" + $R = New-Result $true 'PackageFamilyName' $Hit.Name $Hit.Version $Hit.PackageFullName + if ($Detailed) { return $R } else { return $true } + } + } + } catch {} + } + # --- 2. EXACT DISPLAY NAME ---------------------------------------- # A custom catalogue entry carries the real ARP DisplayName for its ID. $Candidates = New-Object System.Collections.Generic.List[string] From 8c81eb281eaac0d3c215abbea567ff4af08262dd Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:03:33 +0100 Subject: [PATCH 46/50] Emit index pairs and group in jq, avoiding an illegal control character The separator in the SQL group_concat was char(31), and the matching jq split used a \u001f escape. That escape reached the workflow file as a raw unit separator byte, which YAML does not permit, so the workflow failed to parse and the run produced no jobs at all. Any separator safe inside a product code has to be a control character, so the query now emits one row per identifier and jq groups them. No separator, nothing to escape. --- .github/workflows/build-manifest-index.yml | 55 ++++++++++++---------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-manifest-index.yml b/.github/workflows/build-manifest-index.yml index 1fc54d0..bb35293 100644 --- a/.github/workflows/build-manifest-index.yml +++ b/.github/workflows/build-manifest-index.yml @@ -47,35 +47,42 @@ jobs: DB=wingetsrc/Public/index.db test -f "$DB" - # Packages with neither a product code nor a package family name - # cannot be identified this way, so including them is dead weight. - # char(31) as the separator: a unit separator cannot occur inside a - # product code, whereas a pipe or comma plausibly could. + # One row per identifier rather than a delimited list. Any separator + # has to be a character that cannot appear inside a product code, and + # the obvious safe choice is a control character, which is not legal + # in a YAML workflow file. Emitting pairs and grouping in jq avoids + # the question entirely. + # + # Packages with neither a product code nor a package family name are + # excluded: they cannot be identified this way, so they are dead + # weight in a file every endpoint downloads. sqlite3 "$DB" -json " - SELECT p.id AS Id, - p.name AS Name, - p.latest_version AS Version, - (SELECT group_concat(productcode, char(31)) FROM productcodes2 WHERE package = p.rowid) AS ProductCodes, - (SELECT group_concat(pfn, char(31)) FROM pfns2 WHERE package = p.rowid) AS Pfns - FROM packages p - WHERE EXISTS (SELECT 1 FROM productcodes2 WHERE package = p.rowid) - OR EXISTS (SELECT 1 FROM pfns2 WHERE package = p.rowid) - ORDER BY p.id;" > packages.json + SELECT p.id AS Id, p.name AS Name, p.latest_version AS Version, + 'P' AS Kind, c.productcode AS Value + FROM packages p JOIN productcodes2 c ON c.package = p.rowid + UNION ALL + SELECT p.id, p.name, p.latest_version, + 'F', f.pfn + FROM packages p JOIN pfns2 f ON f.package = p.rowid + ORDER BY 1;" > pairs.json NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) jq --arg now "$NOW" ' - { Generated: $now, - Source: "winget source2 index", - PackageCount: length, - Packages: (map({ - key: .Id, + group_by(.Id) + | map({ + key: .[0].Id, value: { - Name: .Name, - Version: .Version, - ProductCodes: ((.ProductCodes // "") | if . == "" then [] else split("") end), - Pfns: ((.Pfns // "") | if . == "" then [] else split("") end) - }}) | from_entries) } - ' packages.json > Index/Detection.json + Name: .[0].Name, + Version: .[0].Version, + ProductCodes: [ .[] | select(.Kind == "P") | .Value ], + Pfns: [ .[] | select(.Kind == "F") | .Value ] + }}) + | from_entries + | { Generated: $now, + Source: "winget source2 index", + PackageCount: length, + Packages: . } + ' pairs.json > Index/Detection.json echo "detection index: $(jq -r .PackageCount Index/Detection.json) packages, $(du -h Index/Detection.json | cut -f1)" From b8d09773b7363937acd94b01d5da9b39631bbee8 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:06:47 +0100 Subject: [PATCH 47/50] Hash-look-up product codes instead of probing the registry per code The winget source carries every product code a package has ever shipped: Mozilla.Firefox alone has 5205, one per locale and version. Probing those across three hives is 15,615 registry reads, measured at ~384 seconds, which would exceed an N-central scan interval on its own. The uninstall key names are now enumerated once into a case-insensitive dictionary and each candidate is a hash lookup, so the cost is ~230 registry reads regardless of how many codes a package carries. Firefox drops from ~384s to 0.47s. Ordinal-ignore-case is required, not cosmetic: the source index stores codes normalised to lower case ("7-zip") while the real key is "7-Zip". --- Public/Test-TecharyApp.ps1 | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 323197f..7c0e335 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -87,13 +87,31 @@ function Test-TecharyApp { } catch {} } - foreach ($Code in $ProductCodes) { + if ($ProductCodes.Count -gt 0) { + # Enumerate the machine's uninstall key NAMES once and hash-look-up each + # candidate, rather than probing the registry per code. + # + # The winget source carries every product code a package has ever + # shipped: Mozilla.Firefox alone has 5205, one per locale and version. + # Probing those across three hives is 15,615 registry reads and took + # ~384 seconds measured, which would exceed an N-central scan interval + # on its own. This is ~230 reads regardless of how many codes a package + # has. Ordinal-ignore-case because the index stores codes normalised to + # lower case ("7-zip") while the real key is "7-Zip", and the registry + # itself is case-insensitive. + $ArpKeys = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($Hive in $Hives) { - $Key = Join-Path $Hive $Code - if (Test-Path $Key) { - $Item = Get-ItemProperty -Path $Key -ErrorAction SilentlyContinue - Write-Verbose "Matched on ProductCode '$Code' at $Key" - $R = New-Result $true 'ProductCode' $Item.DisplayName $Item.DisplayVersion $Key + foreach ($Key in (Get-ChildItem -Path $Hive -ErrorAction SilentlyContinue)) { + if (-not $ArpKeys.ContainsKey($Key.PSChildName)) { $ArpKeys[$Key.PSChildName] = $Key.PSPath } + } + } + + foreach ($Code in $ProductCodes) { + $Path = $null + if ($ArpKeys.TryGetValue($Code, [ref]$Path)) { + $Item = Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue + Write-Verbose "Matched on ProductCode '$Code' at $Path" + $R = New-Result $true 'ProductCode' $Item.DisplayName $Item.DisplayVersion $Path if ($Detailed) { return $R } else { return $true } } } From b312fe23a85c036d34bdf4ac58ec6fd171e65993 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:11:03 +0100 Subject: [PATCH 48/50] Index every package, not only those with a product code The detection index excluded packages whose manifests declare neither a product code nor an MSIX package family name, on the grounds that they cannot be matched by code. That also dropped their canonical display name, which is the more broadly useful field: it is what bridges a package ID to its ARP entry, and "Valve.Steam" never matches "Steam" on its own. Those packages were therefore undetectable by any route. Every package now gets an entry. A name-only row costs about 80 bytes. Detection also uses the canonical name from the index as a name candidate. Product codes alone are not sufficient even where the index has them: Chrome's installed product code varies by build, so the three its manifests declare missed a live install of Google Chrome 153.0.8010.52, which the canonical name then matched exactly. --- .github/workflows/build-manifest-index.yml | 14 +++++++++++--- Public/Test-TecharyApp.ps1 | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-manifest-index.yml b/.github/workflows/build-manifest-index.yml index bb35293..ddaaf15 100644 --- a/.github/workflows/build-manifest-index.yml +++ b/.github/workflows/build-manifest-index.yml @@ -53,9 +53,11 @@ jobs: # in a YAML workflow file. Emitting pairs and grouping in jq avoids # the question entirely. # - # Packages with neither a product code nor a package family name are - # excluded: they cannot be identified this way, so they are dead - # weight in a file every endpoint downloads. + # Every package gets an entry, including those with neither a product + # code nor a package family name. Those cannot be matched by code, + # but the entry still carries the canonical display name, and that is + # what bridges an ID to its ARP entry: "Valve.Steam" never matches + # "Steam" on its own. A name-only row costs about 80 bytes. sqlite3 "$DB" -json " SELECT p.id AS Id, p.name AS Name, p.latest_version AS Version, 'P' AS Kind, c.productcode AS Value @@ -64,6 +66,12 @@ jobs: SELECT p.id, p.name, p.latest_version, 'F', f.pfn FROM packages p JOIN pfns2 f ON f.package = p.rowid + UNION ALL + SELECT p.id, p.name, p.latest_version, + 'N', NULL + FROM packages p + WHERE NOT EXISTS (SELECT 1 FROM productcodes2 WHERE package = p.rowid) + AND NOT EXISTS (SELECT 1 FROM pfns2 WHERE package = p.rowid) ORDER BY 1;" > pairs.json NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index 7c0e335..ceec5ea 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -150,6 +150,16 @@ function Test-TecharyApp { if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } } catch {} + # The detection index carries each package's canonical display name, which + # is what bridges an ID to its ARP entry: "Google.Chrome" never matches + # "Google Chrome" on its own. + # + # This matters more than it looks. Product codes alone are not sufficient + # even when the index has some: Chrome's installed product code varies by + # build, so the three the manifests declare missed a live install that the + # canonical name then matched exactly. + if ($Entry -and $Entry.Name) { $Candidates.Add($Entry.Name) } + $AllArp = foreach ($Hive in $Hives) { Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue } From e825e4e19cca4a34c160eca8e00bdd5b7312d961 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:13:19 +0100 Subject: [PATCH 49/50] Reject architecture and channel folders when picking the latest version A winget package folder can contain siblings that are not versions. Discord carries x86, arm64, Canary, PTB and Development alongside 144 real versions. The version key ranked those as versions because it simply extracted digits: "x86" yields 86, which outranks the leading component of 1.0.9258 and won the sort, so resolution then looked for an installer manifest inside the x86 folder and threw. Discord.Discord could not be installed at all. Version folders start with a digit, so names that do not are now rejected outright. The original implementation on main filtered on ^\d; that guard was lost when the sort was rewritten to handle non-[Version] formats. Verified: Discord.Discord resolves to 1.0.9258, and x86, arm64, Canary, PTB and Development are rejected while 1.0.9258, v2.1 and 20240101 are kept. --- Private/Resolve-GitHubManifest.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Private/Resolve-GitHubManifest.ps1 b/Private/Resolve-GitHubManifest.ps1 index d69ab32..1ad21e8 100644 --- a/Private/Resolve-GitHubManifest.ps1 +++ b/Private/Resolve-GitHubManifest.ps1 @@ -6,6 +6,13 @@ function Get-ManifestVersionKey { # discarding the failures silently dropped real releases, and threw outright # for packages where no folder happened to parse. Build a zero-padded key so # ordinary string sorting gives correct numeric ordering instead. + # A winget version folder starts with a digit. Siblings like "x86", + # "arm64", "Canary", "PTB" and "Development" are architectures or release + # channels, not versions, and must not be ranked as such: "x86" yields 86, + # which outranks the first component of 1.0.9258 and wins the sort. Discord + # carries all five of those alongside 144 real versions. + if ($Name -notmatch '^v?\d') { return $null } + $Numbers = [regex]::Matches($Name, '\d+') | ForEach-Object { $_.Value } if (-not $Numbers) { return $null } From 34592007bb1ec70c03987c8e9d7c3fb18349fd48 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 13:15:51 +0100 Subject: [PATCH 50/50] Use canonical names for exact matching only Adding the index's canonical display name to the substring tier as well as the exact tier reported applications that are not installed. Those names are short and generic: "Steam" matched the MSIX package MSTeams and reported Valve.Steam as installed on a machine that has never had it, and "Git" would match "GitHub CLI" the same way. Candidates are now split. Exact comparison uses the supplied name, the custom catalogue display name and the index canonical name. Substring and MSIX name matching use only the first two, which is the behaviour before the canonical name was introduced. Verified against ground truth on a real machine, 8 packages, no failures: 7-Zip, Firefox, Chrome, Zoom, Git and Windows Terminal detected as installed, Steam and Discord as not installed. --- Public/Test-TecharyApp.ps1 | 46 +++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/Public/Test-TecharyApp.ps1 b/Public/Test-TecharyApp.ps1 index ceec5ea..823f405 100644 --- a/Public/Test-TecharyApp.ps1 +++ b/Public/Test-TecharyApp.ps1 @@ -143,28 +143,39 @@ function Test-TecharyApp { # --- 2. EXACT DISPLAY NAME ---------------------------------------- # A custom catalogue entry carries the real ARP DisplayName for its ID. - $Candidates = New-Object System.Collections.Generic.List[string] - $Candidates.Add($Name) + # Two lists, because a name safe to compare exactly is not safe to compare + # as a substring. + # + # The detection index carries each package's canonical display name, which + # is what bridges an ID to its ARP entry: "Google.Chrome" never matches + # "Google Chrome" on its own, and product codes alone do not cover it + # because Chrome's installed code varies by build. + # + # Those names are short and generic, so they are used for exact comparison + # ONLY. Feeding them to the substring tier reports anything that merely + # contains them: "Steam" matches the MSIX package "MSTeams", and "Git" + # matches "GitHub CLI". Both were observed. + $ExactCandidates = New-Object System.Collections.Generic.List[string] + $LooseCandidates = New-Object System.Collections.Generic.List[string] + + $ExactCandidates.Add($Name) + $LooseCandidates.Add($Name) + try { $CustomApp = Get-CustomApp -Id $Name -NoRefresh - if ($CustomApp -and $CustomApp.DisplayName) { $Candidates.Add($CustomApp.DisplayName) } + if ($CustomApp -and $CustomApp.DisplayName) { + $ExactCandidates.Add($CustomApp.DisplayName) + $LooseCandidates.Add($CustomApp.DisplayName) + } } catch {} - # The detection index carries each package's canonical display name, which - # is what bridges an ID to its ARP entry: "Google.Chrome" never matches - # "Google Chrome" on its own. - # - # This matters more than it looks. Product codes alone are not sufficient - # even when the index has some: Chrome's installed product code varies by - # build, so the three the manifests declare missed a live install that the - # canonical name then matched exactly. - if ($Entry -and $Entry.Name) { $Candidates.Add($Entry.Name) } + if ($Entry -and $Entry.Name) { $ExactCandidates.Add($Entry.Name) } $AllArp = foreach ($Hive in $Hives) { Get-ItemProperty -Path (Join-Path $Hive '*') -ErrorAction SilentlyContinue } - foreach ($Candidate in $Candidates) { + foreach ($Candidate in $ExactCandidates) { $Exact = $AllArp | Where-Object { $_.DisplayName -eq $Candidate } | Select-Object -First 1 if ($Exact) { Write-Verbose "Matched exactly on DisplayName '$Candidate'" @@ -174,7 +185,9 @@ function Test-TecharyApp { } # --- 3. SUBSTRING (imprecise, kept for compatibility) ------------- - foreach ($Candidate in $Candidates) { + # Loose list only. A canonical name from the index is too generic to + # widen with wildcards. + foreach ($Candidate in $LooseCandidates) { # Escaped: an unescaped name containing [ or ] is a wildcard pattern, # which previously made the comparison silently match nothing. $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" @@ -196,7 +209,10 @@ function Test-TecharyApp { [Security.Principal.WindowsBuiltInRole]::Administrator) } catch {} - foreach ($Candidate in $Candidates) { + # Loose list only, for the same reason: "Steam" as a wildcard matches the + # MSIX package MSTeams, which was reported as Valve.Steam being installed + # on a machine that has never had it. + foreach ($Candidate in $LooseCandidates) { $Pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($Candidate) + "*" $Msix = $null try {