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..ddaaf15 --- /dev/null +++ b/.github/workflows/build-manifest-index.yml @@ -0,0 +1,127 @@ +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. + # Push and manual dispatch work from any 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 + + # --------------------------------------------------------------- + # 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" + + # 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. + # + # 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 + 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 + 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) + jq --arg now "$NOW" ' + group_by(.Id) + | map({ + key: .[0].Id, + value: { + 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)" + + # --------------------------------------------------------------- + # 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 }} + 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" + 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" + + # 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 + 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/" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aadb73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Built in CI and published to the manifest-index branch, never committed here. +Index/Manifests.json +Index/Detection.json 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 - - -} 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/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() 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..d528c3e --- /dev/null +++ b/Private/Get-CustomApp.ps1 @@ -0,0 +1,82 @@ +function Get-CustomApp { + param ( + [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 --- + # We use the "Raw" GitHub URL so we get just the JSON text. + # Structure: https://raw.githubusercontent.com//// + # 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 = -not $NoRefresh + 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 + + # 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 Custom Catalog ($($_.Exception.Message)). Using local copy." -Severity Warning + Remove-Item "$CachePath.tmp" -Force -ErrorAction SilentlyContinue + } + + # --- 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 +} 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 new file mode 100644 index 0000000..69ce5f6 --- /dev/null +++ b/Private/Get-ManifestIndex.ps1 @@ -0,0 +1,95 @@ +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]$NoRefresh, + [switch]$Force + ) + + $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 } + + # -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 ($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-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, + [switch]$NoRefresh + ) + + $Index = Get-ManifestIndex -NoRefresh:$NoRefresh + 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/Private/Resolve-GitHubManifest.ps1 b/Private/Resolve-GitHubManifest.ps1 new file mode 100644 index 0000000..1ad21e8 --- /dev/null +++ b/Private/Resolve-GitHubManifest.ps1 @@ -0,0 +1,121 @@ +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. + # 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 } + + # 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/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/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/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 new file mode 100644 index 0000000..85f424f --- /dev/null +++ b/Public/Get-GitHubInstaller.ps1 @@ -0,0 +1,150 @@ +function Get-GitHubInstaller { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$Id, + [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, 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 + ) + + Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" + + # 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 + } + } + } + + # 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 + + 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 { } + } + + if (-not $Meta) { + Write-PackagerLog -Message "GitHub Scraping Failed: $Reason" -Severity Error + throw $_ + } + } + } + + try { + $SelectedUrl = $Meta.Url + $SelectedArgs = $Meta.SilentArgs + $SelectedType = $Meta.InstallerType + $SelectedCode = $Meta.ProductCode + $LatestVersion = $Meta.Version + + # --- 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 + $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 + + # 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 + InstallerPath = $FullPath + FileName = $FileName + SilentArgs = $SelectedArgs + InstallerType = $SelectedType + ProductCode = $SelectedCode + } + } + catch { + Write-PackagerLog -Message "Installer download failed: $_" -Severity Error + throw $_ + } +} diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 new file mode 100644 index 0000000..9bdd35e --- /dev/null +++ b/Public/Install-AppPackage.ps1 @@ -0,0 +1,125 @@ +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 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 { + Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "MSIX Provisioned Successfully." + $Provisioned = $true + } + catch { + 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 + } + ".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..2662fef --- /dev/null +++ b/Public/Install-NableAgent.ps1 @@ -0,0 +1,107 @@ +function Install-NableAgent { + [CmdletBinding()] + param ( + [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" + $DownloadPath = "C:\Temp\AppPackager" + $FileName = "Nable_RMMInstaller.exe" + $InstallerPath = Join-Path $DownloadPath $FileName + $DownloadUrl = "https://$ServerAddress/download/current/winnt/N-central/WindowsAgentSetup.exe" + + try { + 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" + + 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" + + $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." + 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). 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 $_ + } +} diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 new file mode 100644 index 0000000..fbf2ce8 --- /dev/null +++ b/Public/Install-TecharyApp.ps1 @@ -0,0 +1,80 @@ +$ProgressPreference = 'SilentlyContinue' + +function Install-TecharyApp { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [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 -GitHubToken $GitHubToken -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." + + $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) { + $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 + # 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 + } +} 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..823f405 --- /dev/null +++ b/Public/Test-TecharyApp.ps1 @@ -0,0 +1,233 @@ +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, + + [switch]$Detailed + ) + + $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 + } + } + + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # --- 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 -and $Indexed.ProductCode) { $ProductCodes.Add($Indexed.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 ($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) { + 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 } + } + } + } + + 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. + # 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) { + $ExactCandidates.Add($CustomApp.DisplayName) + $LooseCandidates.Add($CustomApp.DisplayName) + } + } catch {} + + 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 $ExactCandidates) { + $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. SUBSTRING (imprecise, kept for compatibility) ------------- + # 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) + "*" + $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 {} + + # 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 { + 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 } + } + } + + # --- 5. NOT FOUND ------------------------------------------------- + $R = New-Result $false 'None' $null $null $null + if ($Detailed) { return $R } else { return $false } +} diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 new file mode 100644 index 0000000..7b875d2 --- /dev/null +++ b/Public/Uninstall-TecharyApp.ps1 @@ -0,0 +1,221 @@ +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)..." + + # 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) { + # 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) + } + } 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) ($($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 + $Scope = "for all users" + } else { + Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop + $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 { + # -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 deprovision: $($Prov.PackageName)" -ForegroundColor Yellow + continue + } + + try { + 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 deprovision $($Prov.DisplayName): $($_.Exception.Message)" -Severity Error + } + } + + 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") { + $Type = "MSI" + if ($App.UninstallString -match '{[A-F0-9-]+}') { + $Guid = $Matches[0] + $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 + # 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 + } + + if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { + $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + $ArgsPart = $Matches[3].Trim() + + $UninstallString = $Exe + $Arguments = $ArgsPart + + 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" + + if ($WhatIf) { + Write-Host "[WhatIf] Would execute: $UninstallString $Arguments" -ForegroundColor Yellow + return + } + + # 4. EXECUTE REMOVAL + try { + 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 { + Write-PackagerLog -Message "Uninstallation finished with Exit Code: $($Process.ExitCode)" -Severity Warning + } + } + catch { + Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error + } +} diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 new file mode 100644 index 0000000..783d509 --- /dev/null +++ b/Public/Write-PackagerLog.ps1 @@ -0,0 +1,52 @@ +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 + # 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 + # N-able can pick this up easily. + # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) + # + # 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 + } + + $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 -ErrorAction Stop + } + catch { + # Event logging is best-effort; the file log above is the system of record. + } +} diff --git a/README.md b/README.md index fe48db8..6746164 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,43 @@ -ChatGPT Image Oct 30, 2025, 09_09_28 AM +# TecharyGet -# TecharyGet PowerShell Module +**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. -> **Author:** Adam Sweetapple +## ๐Ÿš€ Key Features -> **Purpose:** Install and uninstall software using custom Winget logic and external installer definitions (including EXE, MSI, ZIP, MSIX). +* **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. +* **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. -## Features +--- -* Installs apps from Winget using GitHub-hosted YAMLs +## ๐Ÿ“ฆ Installation -* Supports MSI, EXE, ZIP, and MSIX installers +1. Copy the `TecharyGet` folder to your PowerShell Modules directory: + * `C:\Program Files\WindowsPowerShell\Modules\` +--- -* Custom app support with static URLs and parameters (e.g. N-Able, myDPD) +## ๐Ÿ› ๏ธ Usage Examples -* Works with Intune deployments and SYSTEM-level context +### 1. Installing Applications +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. -* Full uninstall logic via Winget or Registry fallback +```powershell +# Install a standard app (Latest Version) +Install-TecharyApp -Id "7zip.7zip" -* Architecture-aware (x64, ARM64) - -## Available Commands -**Install an App** -```Powershell -Install-TecharyApp -AppName "7zip" -``` - -**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** - -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 -``` - -๐Ÿ”ง 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 +# Install a specific ID from your private catalog +Install-TecharyApp -Id "MyDPD" ``` -## AppMap Configuration +For the Nable Agent; it is slightly different, below is a example -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" -} -``` - -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" -} +```powershell +Install-NableAgent ` + -CustomerID "" ` + -Token "" ` + -ServerAddress "" ``` - -## Intune Packager - -The **Intune-Packager.ps1** allows the ease of creation of Intunewin files for Intune upload and App deployment. - -To use, download the **Intune-Packager.ps1** file, right-click and Run with Powershell, you will be given the below window. - -image - - -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 - -For N-Able install you will see the parameters needed to create the Customer specific installer: - -image - - - - -## Notes - -* The module detects CPU architecture and installs the correct version. - -* All downloads are logged to C:\Logs\TecharyGetLogs\TecharyGet.log - -* 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 - -* PowerShell 5.1 and 7+ - -## Troubleshooting - -* โ— App not found? โ†’ Make sure it's defined in AppMap.ps1 - -* โ— 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 diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 414d048..2aaab2d 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -1,49 +1,104 @@ @{ - # Script module file associated with this manifest - RootModule = 'TecharyGet.psm1' + # Script module or binary module file associated with this manifest. + RootModule = 'TecharyGet.psm1' - # Version of this module - ModuleVersion = '1.4' + # Version number of this module. + ModuleVersion = '2.4' # ID used to uniquely identify this module - GUID = '8d777e7e-fd28-4e34-bf9d-0c325bb81a76' + GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' # Author of this module - Author = 'Adam Sweetapple' + Author = 'Adam Sweetapple' # Company or vendor of this module - CompanyName = 'Techary' + CompanyName = 'Techary' - # Copyright - Copyright = '(c) 2025 Techary. All rights reserved.' + # Copyright statement for this module + Copyright = '(c) 2026 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.' + # 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.' - # Minimum version of PowerShell required - PowerShellVersion = '5.1' + # 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 + + # -- Intune Packaging Tools -- + 'New-IntunePackage', # The CLI Packager (with Detect/Uninstall generation) + 'New-IntunePackageUI', # The GUI Packager - # Functions to export - FunctionsToExport = "Install-TecharyApp","Uninstall-TecharyApp","Help-TecharyApp","Get-TecharyAppList","Update-TecharyGetModule" + # -- Utilities -- + 'Write-PackagerLog' + ) - # Cmdlets to export - CmdletsToExport = @() + # Cmdlets to export from this module + CmdletsToExport = @() - # Variables to export - VariablesToExport = @() + # Variables to export from this module + VariablesToExport = '*' - # Aliases to export - AliasesToExport = @() + # Aliases to export from this module + AliasesToExport = @() - # Private data to pass to PowerShell - PrivateData = @{ + # List of all modules packaged with this module + # NestedModules = @() + # List of all files packaged with this module + # FileList = @() + + # Private data to pass to the module specified in RootModule/ModuleToProcess + PrivateData = @{ PSData = @{ - Tags = @('winget', 'installer', 'automation', 'techary', 'uninstall', 'intune') - ProjectUri = 'https://github.com/Techary/TecharyGet' + # 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 = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' } } + + # Minimum version of the Windows PowerShell engine required by this module + PowerShellVersion = '5.1' + + # Minimum version of the Common Language Runtime (CLR) required by this module + # 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 = @() } + diff --git a/TecharyGet.psm1 b/TecharyGet.psm1 index 749d731..fc6645f 100644 --- a/TecharyGet.psm1 +++ b/TecharyGet.psm1 @@ -1,463 +1,25 @@ -#region Globals -$script:folderPath = "C:\Logs\TecharyGetLogs" -$ProgressPreference = 'SilentlyContinue' -#endregion +$Root = $PSScriptRoot +if (-not $Root) { $Root = Split-Path -Parent $MyInvocation.MyCommand.Path } -#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" - } +# 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 } - $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 + foreach ($File in (Get-ChildItem -Path $Dir -Filter '*.ps1' -File)) { + try { + . $File.FullName } - "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 + catch { + throw "TecharyGet: failed to load '$Scope\$($File.Name)': $($_.Exception.Message)" } } - - 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 - } - } +$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 } } -#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