Beta - #1
Merged
Merged
Beta#1
Conversation
Updated the README.md to include comprehensive module features and usage examples.
Removed introductory text and several sections about uninstalling applications, creating Intune packages, special N-able agent deployment, private catalog configuration, logging, telemetry, and troubleshooting.
Removed import instructions and simplified usage examples.
Added new functions for managing Techary applications.
Updated Install-NableAgent function to improve parameter handling and validation logic.
Added installation instructions for Nable Agent.
Added information about the App ID for installation.
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.
…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.
…ning 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.
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.
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.
…rror 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.
Cut GitHub API dependency and fix silent-failure paths
Publish a prebuilt manifest index to remove the API from the install path
Detect applications by ProductCode instead of substring matching
Make MSIX removal work under SYSTEM and stop deprovisioned apps returning
Stop leaving Appx sideloading policy permanently enabled
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.
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.
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".
Index every winget package for detection, not just the curated catalogue
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.
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.
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.
Index every package, and use canonical names for exact matching only
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.