Skip to content

Beta - #1

Merged
TecharyJames merged 57 commits into
mainfrom
BETA
Sep 19, 2026
Merged

TecharyJames merged 57 commits into
mainfrom
BETA

Conversation

@TecharyAdam

Copy link
Copy Markdown
Collaborator

No description provided.

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.
TecharyAdam and others added 27 commits March 10, 2026 09:19
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
@TecharyJames
TecharyJames merged commit 59506ca into main Sep 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants