├── README.md └── WingetToMECM.psm1 /README.md: -------------------------------------------------------------------------------- 1 | # WingetToMECM 2 | 3 | ## Overview 4 | 5 | This is a proof of concept that facilitates the creation of MECM Applications using Winget. It is not intended for use in production environments. 6 | 7 | When winget was released at Ignite in 2018 (I think?), my first thought was "I hope they build an integration into MECM, or at least give us a PowerShell module for this." Coming at this from the point of view of a guy who admins MEM and spends a lot of time packaging software, it would be pretty nice to be able to press a button in the console and walk through a wizard that would build an Application to install the latest version of 7-zip or Visual Studio Code using winget.  8 | 9 | A few years later, I still don't have my button. The intention of this module is to showcase what I, as an MEM admin, would like to be able to do with winget.  10 | 11 | ## Goals 12 | 13 | I had a few requirements in mind when writing this module. 14 | 15 | * It should be easy to use. 16 | * Applications created should always install the latest version of a package, they shouldn't need to be updated after creation unless the package being installed changes. 17 | * The module should be able to account for different versions of winget, depending on which is currently installed. 18 | * There should be no need to store content for Applications created with this module. 19 | 20 | ## Usage 21 | 22 | Winget is not designed to be executed by the local system account. For that matter, it doesn't really seem to be built with automation like this as a primary focus. Therefore, Deployment Types must be configured to run as the current user or local system as appropriate for a given package based on how it behaves. 23 | 24 | 25 | For packages that winget would normally install system-wide, configure the Deployment Type to run as System. 26 | For packages that are intended to be installed in the context of the current user, configure the deployment type to run as the current user. 27 | 28 | 29 | In either condition, the Deployment Type may need to be configured to 'Allow users to view and interact with the program installation.' This is because not all packages respect the --silent flag during install/uninstall operations. 30 | 31 | To determine which method to use, run `winget install [packageid]` as a user without administrative privileges. 32 | If the package's installer prompts for administrative elevation, set the Deployment Type to run as the local system. When elevation is not needed to complete the install, the Deployment Type must be configured to run as the current user. 33 | 34 | Even when all of this is completed, some packages may still exhibit behavior that is 'unfriendly' to a Configuration Manager deployment. _Again, this is a proof of concept._ 35 | 36 | Once all of that is figured out, download the module in this repository and install it. **This module requires Administrative privileges to work properly.** 37 | 38 | ```powershell 39 | import-module WingetToMECM.psm1 40 | ``` 41 | 42 | Next, use the Get-WingetMECMApplicationParameters function to get an object that will provide an InstallCommand, UninstallCommand, and DetectionRuleScript. 43 | 44 | ```powershell 45 | $params = Get-WingetMECMApplicationParameters -PackageID 7zip.7zip 46 | ``` 47 | 48 | Lastly, we take the output and use it to create a new Application and "Script Installer" Deployment Type. Set-Clipboard will come in handy here. First we'll need the command lines that the Deployment Type will use for Install and Uninstall operations. 49 | 50 | ```powershell 51 | $params.installcommand | set-clipboard 52 | $params.uninstallcommand | set-clipboard 53 | ``` 54 | 55 | ![](https://user-images.githubusercontent.com/27856660/160049717-57620eda-1cd1-44ee-a958-fdddc65ff58d.png) 56 | 57 | Next, we need a detection rule. Get-WingetMECMApplicationParameters will generate a block of code that we can use as a PowerShell Script Detection Rule. (That's the cool part.) 58 | 59 | ```powershell 60 | $params.DetectionRuleScript | Set-Clipboard 61 | ``` 62 | 63 | ![](https://user-images.githubusercontent.com/27856660/160050194-0afa9f7e-1272-4b73-b221-817fed581b44.png) 64 | 65 | If you want, you can also paste the contents of the DetectionRuleScript property right into the PowerShell ISE (that we're not supposed to use) to see what's going on.  66 | 67 | Finish creating the Deployment Type as you'd like. Be sure to read the notes above regarding whether to configure it to run as the Current User or Local System. 68 | 69 | ![](https://user-images.githubusercontent.com/27856660/160050627-98a48c8b-53ac-4e62-8383-06663b880a0f.png) 70 | 71 | This will leave you with an Application that will always install the current version of the provided winget package. The Detection Rule will compare the installed version with what's available in the winget repository.  72 | 73 | If you deploy the Application as Available, users will be able to "Install" it again when a new version of the software is released and update to the latest version. 74 | 75 | Deploying as Required would automatically keep the installed package up to date as the Deployment Rule script checks to ensure that the latest version is present. 76 | 77 | ## Summary 78 | 79 | This thing works (at least as of right now with Microsoft.DesktopAppInstaller version 1.17.10271.0, which includes winget version 1.2.10271), but it would be really nice to have a native module that didn't have to wrap winget.exe and then do whacky stuff to parse the output.  80 | 81 | As this one sits, there are still manual steps needed that make it difficult to fully automate the creation of Applications. Imagine the possibilities if we could do things like run a simple command to detect if there's an update available for a given package, determine if a package should install as a user or system-wide, and easily download an icon for the installer. (I like a pretty Software Center). 82 | -------------------------------------------------------------------------------- /WingetToMECM.psm1: -------------------------------------------------------------------------------- 1 | <####################################################################################################### 2 | This module is intended to be a proof of concept. Showing the viability of Winget integration with MECM. 3 | 4 | **This module requires Administrative privileges to work properly.** 5 | 6 | It's not intended for use in a production environment. The goal is to show that using Winget to deploy 7 | applications with MECM is very possible in its current state. The biggest challenge is creating a 8 | detection rule for a deployment type as winget does not have an easy way to tell us if a package is 9 | installed or needs updating. 10 | 11 | Winget is not designed to be executed by the local system account. Therefore, Deployment Types must be 12 | configured to run as the current user or local system as appropriate for a given package. 13 | For packages that winget would normally install system-wide, configure the Deployment Type to run as System. 14 | For packages that are intended to be installed in the context of the current user, configure the deployment 15 | type to run as the current user. 16 | 17 | In either condition, the Deployment Type may need to be configured to 'Allow users to view and interact 18 | with the program installation.' This is because not all packages respect the --silent flag during 19 | install/uninstall operations. 20 | 21 | To determine which method to use, run 'winget install [packageid]' as a user without administrative privileges. 22 | If the package's installer prompts for administrative elevation, set the Deployment Type to run as system. When 23 | elevation is not needed to complete the install, the Deployment Type must be configured to run as the current user. 24 | 25 | The Get-WingetMECMApplicationParameters function in this module will output an object that contains three 26 | properties that can be used when creating an Application's Deployment Type. 27 | 28 | InstallCommand - winget command to install the selected package 29 | UninstallCommand - winget command to uninstall the selected package 30 | DetectionRuleScript - The string in this property contains the code for a Powershell Script detection rule 31 | that will detect the presence of the selected package 32 | 33 | #######################################################################################################> 34 | 35 | 36 | 37 | 38 | 39 | <# 40 | .DESCRIPTION 41 | Gets the path to winget.exe. We'll need to run winget as the local system account. 42 | To do this, we'll use get-appxpackage to see if the "Microsoft.DesktopAppInstaller" package is installed. 43 | Then, we'll look inside its installation path for winget.exe and return the full path. 44 | 45 | Used primarily by other functions to make sure that winget exists, but could be ran on its own. 46 | 47 | .EXAMPLE 48 | Get-Wingetpath 49 | 50 | #> 51 | Function Get-Wingetpath { 52 | 53 | $wingetApp = Get-AppxPackage -allusers -Name "Microsoft.DesktopAppInstaller" 54 | 55 | if ($null -eq $wingetApp) { 56 | Throw "Microsoft.DesktopAppInstaller does not appear to be installed." 57 | } 58 | 59 | $wingetPath = "$($wingetApp.InstallLocation)\winget.exe" 60 | 61 | if (test-path -Path $wingetPath) { 62 | Return $wingetPath 63 | } else { 64 | Throw "Could not locate winget.exe in $($wingetApp.InstallLocation)" 65 | } 66 | 67 | } 68 | 69 | <# 70 | .DESCRIPTION 71 | Get package info from the WinGet repository by wrapping "winget show --id" 72 | This function searches the winget repository for the specified package and returns info about it. 73 | Used to get the currently available version of a package. 74 | 75 | This function is here so that Get-MECMWingetApplicationParameters can use the code within to build a 76 | MECM detection rule, but could be used on its own. 77 | 78 | .PARAMETER PackageID 79 | Package ID to search for. Use 'winget search' to look for packages if you need to find the ID 80 | 81 | .EXAMPLE 82 | Get-WingetApplicationDetails -PackageID "Microsoft.VisualStudioCode" 83 | #> 84 | Function Get-WingetApplicationDetails { 85 | [CmdletBinding()] 86 | param ( 87 | [Parameter( 88 | Mandatory=$true, 89 | ValueFromPipeline=$true, 90 | HelpMessage="Winget Package ID to search for." 91 | ) 92 | ] 93 | [string] 94 | $PackageID 95 | ) 96 | 97 | 98 | Begin { 99 | #Verify that winget is present 100 | $wingetPath = Get-Wingetpath 101 | 102 | #Initiate the results to return 103 | $returnObject = @() 104 | } 105 | 106 | Process { 107 | #Execute winget show 108 | $wingetShow = & $wingetPath show --id $PackageID 109 | 110 | #Parse the results. 111 | $Result = $wingetShow[1] 112 | 113 | if ($Result -eq "No package found matching input criteria.") 114 | {#Didn't find any results for the provided ApplicationID 115 | 116 | } elseif (!( [STRING]::IsNullOrEmpty(($wingetShow | Where-Object {$_ -like "Store License Terms: *"})) )) 117 | {#Found a store app 118 | #Found a store app 119 | $version = ($wingetShow | Where-Object {$_ -like "Version:*"}).replace("Version:","").trim() 120 | $Publisher = ($wingetShow | Where-Object {$_ -like "Publisher:*"}).replace("Publisher: ","").trim() 121 | $PublisherURL = ($wingetShow | Where-Object {$_ -like "Publisher URL:*"}).replace("Publisher URL:","").trim() 122 | $Description = ($wingetShow | Where-Object {$_ -like "Description:*"}).replace("Description:","").trim() 123 | $Copyright = ($wingetShow | Where-Object {$_ -like "Copyright:*"}).replace("Copyright:","").trim() 124 | $Agreements = ($wingetShow | Where-Object {$_ -like "Agreements:*"}).replace("Agreements:","").trim() 125 | $Category = ($wingetShow | Where-Object {$_ -like "Category:*"}).replace("Category:","").trim() 126 | $Pricing = ($wingetShow | Where-Object {$_ -like "Pricing:*"}).replace("Pricing:","").trim() 127 | $FreeTrial = ($wingetShow | Where-Object {$_ -like "Free Trial:*"}).replace("Free Trial:","").trim() 128 | $TermsOfTransaction = ($wingetShow | Where-Object {$_ -like "Terms of Transaction:*"}).replace("Terms of Transaction:","").trim() 129 | $SeizureWarning = ($wingetShow | Where-Object {$_ -like "Seizure Warning:*"}).replace("Seizure Warning:","").trim() 130 | $StoreLicenseTerms = ($wingetShow | Where-Object {$_ -like "Store License Terms:*"}).replace("Store License Terms:","").trim() 131 | $InstallerType = ($wingetShow | Where-Object {$_ -like " Type:*"}).replace(" Type:","").trim() 132 | #$InstallerLocale = ($wingetShow | Where-Object {$_ -like " Locale:*"}).replace(" Locale:","").trim() 133 | #$InstallerDownloadURL = ($wingetShow | Where-Object {$_ -like " Download URL:*"}).replace( "Download URL:","").trim() 134 | #$InstallerSHA256 = ($wingetShow | Where-Object {$_ -like " SHA256:*"}).replace( "SHA256:","").trim() 135 | 136 | $ReturnObject += [PSCustomObject]@{ 137 | Result = $Result 138 | ID = $PackageID 139 | Version = $version 140 | Publisher = $Publisher 141 | PublisherURL = $PublisherURL 142 | Description = $Description 143 | Copyright = $Copyright 144 | Agreements = $Agreements 145 | Category = $Category 146 | Pricing = $Pricing 147 | FreeTrial = $FreeTrial 148 | TermsOfTransaction = $TermsOfTransaction 149 | SeizureWarning = $SeizureWarning 150 | StoreLicenseTerms = $StoreLicenseTerms 151 | InstallerType = $InstallerType 152 | #InstallerLocale = $InstallerLocale 153 | #InstallerDownloadURL = $InstallerDownloadURL 154 | #InstallerSHA256 = $InstallerSHA256 155 | } 156 | } elseif ( ( [STRING]::IsNullOrEmpty(($wingetShow | Where-Object {$_ -like "Store License Terms: *"})) ) ) 157 | {#Found a standard app 158 | $version = ($wingetShow | Where-Object {$_ -like "Version: *"}).replace("Version: ","").trim() 159 | $Publisher = ($wingetShow | Where-Object {$_ -like "Publisher: *"}).replace("Publisher: ","").trim() 160 | $PublisherURL = ($wingetShow | Where-Object {$_ -like "Publisher URL: *"}).replace("Publisher URL: ","").trim() 161 | #$Moniker = ($wingetShow | Where-Object {$_ -like "Moniker: *"}).replace("Moniker: ","").trim() 162 | $Description = ($wingetShow | Where-Object {$_ -like "Description: *"}).replace("Description: ","").trim() 163 | $Homepage = ($wingetShow | Where-Object {$_ -like "Homepage: *"}).replace("Homepage: ","").trim() 164 | $License = ($wingetShow | Where-Object {$_ -like "License: *"}).replace("License: ","").trim() 165 | #$LicenseURL = ($wingetShow | Where-Object {$_ -like "License URL: *"}).replace("License URL: ","").trim() 166 | #$PrivacyURL = ($wingetShow | Where-Object {$_ -like "Privacy URL: *"}).replace("Privacy URL: ","").trim() 167 | $InstallerType = ($wingetShow | Where-Object {$_ -like " Type: *"}).replace(" Type: ","").trim() 168 | #$InstallerDownloadURL = ($wingetShow | Where-Object {$_ -like " Download URL: *"}).replace( "Download URL: ","").trim() 169 | #$InstallerSHA256 = ($wingetShow | Where-Object {$_ -like " SHA256: *"}).replace( "SHA256: ","").trim() 170 | 171 | $ReturnObject += [PSCustomObject]@{ 172 | Result = $Result 173 | ID = $PackageID 174 | Version = $version 175 | Publisher = $Publisher 176 | PublisherURL = $PublisherURL 177 | Moniker = $Moniker 178 | Description = $Description 179 | Homepage = $Homepage 180 | License = $License 181 | #LicenseURL = $LicenseURL 182 | #PrivacyURL = $PrivacyURL 183 | InstallerType = $InstallerType 184 | #InstallerDownloadURL = $InstallerDownloadURL 185 | #InstallerSHA256 = $InstallerSHA256 186 | } 187 | }#End Found a standard app 188 | 189 | } 190 | 191 | 192 | End { 193 | return $ReturnObject 194 | } 195 | 196 | } 197 | 198 | <# 199 | .DESCRIPTION 200 | Get info about currently installed packages using winget --export and parsing the json output. Currently, 201 | this is the only way to use winget to determine if an application is installed and what version it is. 202 | 203 | This function is here so that Get-MECMWingetApplicationParameters can use the code within to build a 204 | MECM detection rule, but could be used on its own. 205 | 206 | .PARAMETER outputDir 207 | By default, this function will save a .json file to $env:temp and parse it there. If another location is desired, 208 | this parameter can be used to choose a different path. 209 | 210 | .PARAMETER PackageID 211 | To return the result for a specific PackageID, specifiy it here. Otherwise, all installed packages will be returned. 212 | 213 | .EXAMPLE 214 | Get-WingetInstalledApps -PackageID "Microsoft.VisualStudioCode" 215 | #> 216 | Function Get-WingetInstalledApps { 217 | [CmdletBinding()] 218 | param ( 219 | [Parameter( 220 | Mandatory=$false, 221 | HelpMessage="Provide a path to save the json file output from winget export. %Temp% will be used by default." 222 | )] 223 | [STRING] 224 | $outputDir = $($env:temp), 225 | 226 | [Parameter( 227 | Mandatory=$false, 228 | HelpMessage="Filter the results to a specific PackageID" 229 | )] 230 | [STRING] 231 | $PackageID ) 232 | #Verify that winget is present 233 | $wingetPath = Get-wingetpath 234 | 235 | #Make sure that the output dir exists 236 | if (!(Test-path $outputDir)) { 237 | throw [System.AddIn.Hosting.InvalidPipelineStoreException]::New("Unable to access directory $outputDir") 238 | } else { 239 | $outFile = "$($outputDir)\WingetOutput$(Get-date -format FileDateTime).json" 240 | } 241 | 242 | <# 243 | Export info about the currently installed applications to a json file that we can parse. 244 | This is the only good way to get this information for now. 245 | #> 246 | Try { 247 | & $wingetPath export -o $outfile --include-versions | out-null 248 | } catch { 249 | Throw $_ 250 | } 251 | 252 | #Winget Export produces a file containing application and version info for currently installed products 253 | If (! (Test-path $outFile)) { 254 | throw [System.IO.FileNotFoundException]::New("Unable to access exported file $outFile") 255 | } else { 256 | $wingetJson = Get-Content -Path $outFile | ConvertFrom-Json 257 | } 258 | 259 | #Import and parse the json 260 | $ReturnObject = @() 261 | Foreach ($jsonSource in $wingetJson.Sources) { 262 | $thisPackageSource = $jsonSource.SourceDetails.Name 263 | Foreach ($package in $jsonSource.packages) { 264 | $thisPackageIdentifier = $Package.PackageIdentifier 265 | $thisPackageVersion = $Package.Version 266 | 267 | $ReturnObject += [PSCustomObject]@{ 268 | PackageIdentifier = $thisPackageIdentifier; 269 | PackageVersion = $thisPackageVersion; 270 | PackageSource = $thisPackageSource 271 | } 272 | } 273 | } 274 | 275 | if ($PackageID) { 276 | return $ReturnObject | Where-Object {$_.PackageIdentifier -eq $PackageID} 277 | } else { 278 | return $ReturnObject 279 | } 280 | } 281 | 282 | <# 283 | .DESCRIPTION 284 | This function will output an object that contains an install command line, uninstall command line and detection rule that 285 | can be used to build an Application Deployment Type in MECM. 286 | 287 | Winget is not designed to be executed by the local system account. Therefore, Deployment Types must be 288 | configured to run as the current user or local system as appropriate for a given package. 289 | For packages that winget would normally install system-wide, configure the Deployment Type to run as System. 290 | For packages that are intended to be installed in the context of the current user, configure the deployment 291 | type to run as the current user. 292 | In either condition, the Deployment Type may need to be be configured to 'Allow users to view and interact 293 | ith the program installation.' This is because not all packages respect the --silent flag during 294 | install/uninstall operations. 295 | 296 | To determine which method to use, run 'winget install [packageid]' as a user without administrative priviledges. 297 | If the package's installer prompts for administrative elevation, set the Deployment Type to run as system. When 298 | elevation is not needed to complete the install, the Deployment Type must be configured to run as the curren user. 299 | 300 | .PARAMETER PackageID 301 | The PackageID for the winget package to install. Use Winget Search to find a PackageID to install. 302 | 303 | .EXAMPLE 304 | $parameters = Get-WingetMECMApplicationParameters -PackageID "Microsoft.VisualStudioCode" 305 | $parameters.DetectionRuleScript | Set-Clipboard 306 | 307 | #$parameters.InstallCommand - command line to use as the Install Program 308 | #$parameters.UninstallCommand - command line to use as the Uninstall Program 309 | #$parameters.DetectionRuleScript - The text in this property is a script that can be copy/pasted into a script detection rule 310 | #> 311 | Function Get-WingetMECMApplicationParameters { 312 | 313 | [CmdletBinding()] 314 | param ( 315 | [Parameter()] 316 | [STRING] 317 | $PackageID 318 | ) 319 | 320 | $AppParams = [PSCustomObject]@{ 321 | InstallCommand = "" 322 | UninstallCommand = "" 323 | DetectionRuleScript = "" 324 | } 325 | 326 | #region Build Detection Rule 327 | <# 328 | Hold on, this is a wild ride. We're going to use get-command to get the code contained by the 329 | Get-WingetPath, Get-WingetInstalledApps and Get-WingetApplicationDetails functions in this module. 330 | Then we'll build the text of a script that will be added to the return object of this function. 331 | That script can be dropped into an MECM Deployment Type's detection rule. 332 | #> 333 | $RuleString = "" 334 | 335 | $RuleString += "Function Get-WingetPath {" 336 | $RuleString += (Get-command -Name Get-Wingetpath).ScriptBlock 337 | $RuleString += "}`n" 338 | 339 | 340 | $RuleString += "Function Get-WingetInstalledApps {" 341 | $RuleString += (Get-Command -Name Get-WingetInstalledApps).ScriptBlock 342 | $RuleString += "}`n" 343 | 344 | $RuleString += "Function Get-WingetApplicationDetails {" 345 | $RuleString += (Get-Command -Name Get-WingetApplicationDetails).ScriptBlock 346 | $RuleString += "}`n" 347 | 348 | $RuleString += @' 349 | $InstallStatus = Get-WingetInstalledApps -PackageId 350 | '@ 351 | $RuleString += " $($PackageID)`n" 352 | 353 | $RuleString += @' 354 | $appdetails = Get-WingetApplicationDetails -PackageId 355 | '@ 356 | 357 | $RuleString += " $($PackageID)`n" 358 | 359 | $RuleString += @' 360 | if ($appdetails.version -eq $InstallStatus.packageversion) { 361 | Return "Installed" 362 | } 363 | '@ 364 | $AppParams.DetectionRuleScript = $RuleString 365 | #endregion 366 | 367 | 368 | #region Command Lines 369 | <# 370 | We have to assume that the target device might have a different version of winget than what is on the one running this function. 371 | Install and Uninstall commands should find the path to the currently installed version of winget. 372 | #> 373 | 374 | $AppParams.InstallCommand = @' 375 | cmd /c echo start-process -wait -filepath "$((Get-AppxPackage -AllUsers -Name Microsoft.DesktopAppInstaller).installlocation)\winget.exe" 376 | '@ 377 | $AppParams.InstallCommand += " -argumentlist 'install --id $PackageID --silent --accept-package-agreements --accept-source-agreements' | powershell.exe -command -" 378 | 379 | $AppParams.UninstallCommand = @' 380 | cmd /c echo start-process -wait -filepath "$((Get-AppxPackage -AllUsers -Name Microsoft.DesktopAppInstaller).installlocation)\winget.exe" 381 | '@ 382 | $AppParams.UninstallCommand += " -argumentlist 'uninstall --id $PackageID --silent --accept-source-agreements' | powershell.exe -command -" 383 | 384 | 385 | #endregion 386 | 387 | Return $AppParams 388 | 389 | } 390 | --------------------------------------------------------------------------------