├── .github └── workflows │ └── CI.yml ├── .gitignore ├── CHANGELOG.md ├── Install-WinGet.ps1 ├── LICENSE.txt ├── README.md ├── build.ps1 ├── src ├── Cobalt.ps1 └── Cobalt.psd1 └── test └── Cobalt.tests.ps1 /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | release: 11 | types: [ published ] 12 | 13 | jobs: 14 | Build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v3 19 | - name: Setup PowerShell module cache 20 | uses: actions/cache@v3 21 | id: cacher 22 | with: 23 | path: "~/.local/share/powershell/Modules" 24 | key: crescendo-1.1-preview-cache 25 | - name: Install Crescendo 26 | if: steps.cacher.outputs.cache-hit != 'true' 27 | shell: pwsh 28 | run: Install-Module Microsoft.PowerShell.Crescendo -AllowPrerelease -RequiredVersion 1.1.0-Preview01 -Force 29 | - name: Build the module with Crescendo 30 | shell: pwsh 31 | run: ./build.ps1 32 | - name: Bundle up module 33 | uses: actions/upload-artifact@v3 34 | with: 35 | name: module 36 | path: ./src/ 37 | Test: 38 | needs: Build 39 | runs-on: windows-latest 40 | steps: 41 | - name: Checkout Repository 42 | uses: actions/checkout@v3 43 | - name: Download module 44 | uses: actions/download-artifact@v3 45 | with: 46 | name: module 47 | path: C:\Users\runneradmin\Documents\PowerShell\Modules\Cobalt\ 48 | - name: Install WinGet 49 | shell: pwsh 50 | run: .\Install-WinGet.ps1 51 | - name: Test with Pester 52 | run: | 53 | # Codepage 437 being the default input and output OEM code page for US-English systems 54 | # Maybe one day we can all use UTF-8 by default and be done with this nonsense: https://stackoverflow.com/questions/57131654/using-utf-8-encoding-chcp-65001-in-command-prompt-windows-powershell-window/57134096#57134096 55 | # Required for simulated the encoding used by interactive terminals 56 | [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding(437) 57 | Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable @{ 58 | Run = @{ 59 | Exit = $true 60 | } 61 | Output = @{ 62 | Verbosity = 'Detailed' 63 | } 64 | }) 65 | - name: Upload WinGet logs 66 | if: always() 67 | uses: actions/upload-artifact@v3 68 | with: 69 | name: WinGet-logs 70 | path: C:\Users\runneradmin\AppData\Local\Packages\Microsoft.DesktopAppInstaller*\LocalState\DiagOutputDir\ 71 | Publish: 72 | needs: Test 73 | if: github.event_name == 'release' && github.event.action == 'published' 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: Download module 77 | uses: actions/download-artifact@v3 78 | with: 79 | name: module 80 | path: '~/.local/share/powershell/Modules/Cobalt' 81 | - name: Publish Module 82 | env: 83 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 84 | shell: pwsh 85 | run: Write-Output "Publishing..."; Publish-Module -Name Cobalt -NuGetApiKey $env:NUGET_KEY -Exclude @('Cobalt.ps1') -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/Cobalt.psm1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.4.0] - 2023-02-05 8 | ### Changed 9 | - Upgraded to PowerShell Crescendo 1.1 Preview 1 for compiling the module. No functional changes expected. 10 | 11 | ## [0.3.3] - 2023-01-29 12 | ### Fixed 13 | - Hard-code the version of WinGet non-EN-US localization resources to work around microsoft/winget-cli#2783 14 | 15 | ## [0.3.2] - 2023-01-21 16 | ### Fixed 17 | - Package version information for packages with long names correctly displayed in non-UTF-8 encoded output terminals 18 | 19 | ## [0.3.1] - 2022-05-13 20 | ### Fixed 21 | - Package upgrade list functionality now correctly supports non-EN-US localities 22 | 23 | ## [0.3.0] - 2022-05-12 24 | ### Added 25 | - Ability to return a list of packages that qualify for updates 26 | ### Changed 27 | - `Get-WinGetPackage` now returns both installed and available version information 28 | 29 | ## [0.2.1] - 2022-03-12 30 | ### Fixed 31 | - Package uninstallation error handling should now correctly catch failures 32 | 33 | ## [0.2.0] - 2022-03-12 34 | ### Added 35 | - Ability to retrieve package metadata and versions 36 | - Uninstall failure error handling 37 | ### Fixed 38 | - Package installation error handling should now correctly catch failures with non-US English languages 39 | 40 | ## [0.1.0] - 2022-02-06 41 | ### Added 42 | - Upgrade functionality 43 | 44 | ## [0.0.11] - 2022-01-22 45 | ### Changed 46 | - Error output is more targeted to only what failed 47 | 48 | ## [0.0.10] - 2021-12-26 49 | ### Fixed 50 | - Correctly source display language information 51 | - Force console encoding in automated tests 52 | 53 | ## [0.0.9] - 2021-12-21 54 | ### Fixed 55 | - Handle non-US English locales correctly 56 | 57 | ## [0.0.8] - 2021-12-21 58 | ### Fixed 59 | - More dynamic locale-based column parsing 60 | 61 | ## [0.0.7] - 2021-12-04 62 | ### Fixed 63 | - Even more string parsing corrections 64 | 65 | ## [0.0.6] - 2021-12-04 66 | ### Fixed 67 | - Yet more string parsing corrections 68 | 69 | ## [0.0.5] - 2021-12-04 70 | ### Fixed 71 | - Additional string parsing/cleaning corrections 72 | 73 | ## [0.0.4] - 2021-12-04 74 | ### Fixed 75 | - Improved and consolidated string parsing/cleaning 76 | 77 | ## [0.0.3] - 2021-12-04 78 | ### Fixed 79 | - Correctly order output package attributes 80 | 81 | ## [0.0.2] - 2021-12-04 82 | ### Fixed 83 | - Correctly handle output when no results are found with `list` or `search` commands 84 | 85 | ## [0.0.1] - 2021-12-02 86 | - Initial release 87 | -------------------------------------------------------------------------------- /Install-WinGet.ps1: -------------------------------------------------------------------------------- 1 | Install-Module NtObjectManager -Force 2 | Import-Module appx -UseWindowsPowerShell -WarningAction SilentlyContinue 3 | 4 | # GitHub release information 5 | $appxPackageName = 'Microsoft.DesktopAppInstaller' 6 | $msWinGetLatestReleaseURL = 'https://github.com/microsoft/winget-cli/releases/expanded_assets/v1.3.2691' 7 | $msWinGetMSIXBundlePath = ".\$appxPackageName.msixbundle" 8 | $msWinGetLicensePath = ".\$appxPackageName.license.xml" 9 | 10 | # Workaround for no Microsoft Store on Windows Server - I dont know a great way to source this information dynamically 11 | $architecture = 'x64' 12 | $msStoreDownloadAPIURL = 'https://store.rg-adguard.net/api/GetFiles' 13 | $msWinGetStoreURL = 'https://www.microsoft.com/en-us/p/app-installer/9nblggh4nns1' 14 | $msVCLibPattern = "*Microsoft.VCLibs*UWPDesktop*$architecture*appx*" 15 | $msVCLibDownloadPath = '.\Microsoft.VCLibs.UWPDesktop.appx' 16 | $msUIXamlPattern = "*Microsoft.UI.Xaml*$architecture*appx*" 17 | $msUIXamlDownloadPath = '.\Microsoft.UI.Xaml.appx' 18 | $msWinGetExe = 'winget' 19 | $wingetExecAliasPath = "C:\Windows\System32\$msWinGetExe.exe" 20 | 21 | $msWinGetLatestRelease = Invoke-WebRequest -Uri $msWinGetLatestReleaseURL 22 | 23 | # Download the latest MSIX bundle and matching license from GitHub 24 | $msWinGetLatestRelease.links | 25 | Where-Object href -like '*msixbundle' | 26 | Select-Object -Property @{ 27 | Name = 'URI'; 28 | Expression = {$msWinGetLatestRelease.BaseResponse.headers.Server.Product.Name+$_.href} 29 | } | ForEach-Object {Invoke-WebRequest -Uri $_.URI -OutFile $msWinGetMSIXBundlePath} 30 | 31 | # Hopefully this mitigates the sporadic authentication denied errors from GitHub's CDN 32 | Start-Sleep -Seconds 10 33 | 34 | $msWinGetLatestRelease.links | 35 | Where-Object href -Like '*License*xml' | 36 | Select-Object -Property @{ 37 | Name = 'URI'; 38 | Expression = {$msWinGetLatestRelease.BaseResponse.headers.Server.Product.Name+$_.href} 39 | } | ForEach-Object {Invoke-WebRequest -Uri $_.URI -OutFile $msWinGetLicensePath} 40 | 41 | # Download the VC++ redistrubable for UWP apps from the Microsoft Store 42 | (Invoke-WebRequest -Uri $msStoreDownloadAPIURL -Method Post -Form @{type='url'; url=$msWinGetStoreURL; ring='Retail'; lang='en-US'}).links | 43 | Where-Object OuterHTML -Like $msVCLibPattern | 44 | Sort-Object outerHTML -Descending | 45 | Select-Object -First 1 -ExpandProperty href | 46 | ForEach-Object {Invoke-WebRequest -Uri $_ -OutFile $msVCLibDownloadPath} 47 | 48 | # Download the Windows UI redistrubable from the Microsoft Store 49 | (Invoke-WebRequest -Uri $msStoreDownloadAPIURL -Method Post -Form @{type='url'; url=$msWinGetStoreURL; ring='Retail'; lang='en-US'}).links | 50 | Where-Object OuterHTML -Like $msUIXamlPattern | 51 | Sort-Object outerHTML -Descending | 52 | Select-Object -First 1 -ExpandProperty href | 53 | ForEach-Object {Invoke-WebRequest -Uri $_ -OutFile $msUIXamlDownloadPath} 54 | 55 | # Install the WinGet and it's VC++ .msix with the downloaded license file 56 | Add-AppProvisionedPackage -Online -PackagePath $msWinGetMSIXBundlePath -DependencyPackagePath ($msVCLibDownloadPath,$msUIXamlDownloadPath) -LicensePath $msWinGetLicensePath 57 | 58 | # Force the creation of the execution alias with NtObjectManager, since one isn't generated automatically in the current user session 59 | $appxPackage = Get-AppxPackage Microsoft.DesktopAppInstaller 60 | $wingetTarget = Join-Path -Path $appxPackage.InstallLocation -ChildPath ((Get-AppxPackageManifest $appxPackage).Package.Applications.Application | Where-Object Id -eq $msWinGetExe | Select-Object -ExpandProperty Executable) 61 | NtObjectManager\Set-ExecutionAlias -Path $wingetExecAliasPath -PackageName ($appxPackage.PackageFamilyName) -EntryPoint "$($appxPackage.PackageFamilyName)!$msWinGetExe" -Target $wingetTarget -AppType Desktop -Version 3 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Ethan Bergstrom 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/ethanbergstrom/Cobalt/actions/workflows/CI.yml/badge.svg)](https://github.com/ethanbergstrom/Cobalt/actions/workflows/CI.yml) 2 | 3 | # Cobalt 4 | Cobalt is a simple PowerShell Crescendo wrapper for WinGet 5 | 6 | ## Requirements 7 | In addition to PowerShell 5.1+ and an Internet connection on a Windows machine, WinGet must also be installed. Microsoft recommends installing WinGet from the Windows Store as part of the [App Installer](https://www.microsoft.com/en-us/p/app-installer/9nblggh4nns1?activetab=pivot:overviewtab) package. 8 | 9 | ## Install Cobalt 10 | ```PowerShell 11 | Install-Module Cobalt -Force 12 | ``` 13 | 14 | ## Sample usages 15 | ### Search for a package 16 | ```PowerShell 17 | Find-WinGetPackage -ID openJS.nodejs 18 | 19 | Find-WinGetPackage -ID Mozilla.Firefox -Exact 20 | ``` 21 | 22 | ### Get a package's detailed information from the repository 23 | ```PowerShell 24 | Get-WinGetPackageInfo -ID CPUID.CPU-Z -Version 1.95 25 | 26 | Find-WinGetPackage -ID Mozilla.Firefox -Exact | Get-WinGetPackageInfo 27 | ``` 28 | 29 | ### Get all available versions of a package 30 | ```PowerShell 31 | Get-WinGetPackageInfo -ID CPUID.CPU-Z -Versions 32 | 33 | Find-WinGetPackage -ID Mozilla.Firefox -Exact | Get-WinGetPackageInfo -Versions 34 | ``` 35 | 36 | ### Install a package 37 | ```PowerShell 38 | Find-WinGetPackage OpenJS.NodeJS -Exact | Install-WinGetPackage 39 | 40 | Install-WinGetPackage 7zip.7zip 41 | ``` 42 | 43 | ### Install a list of packages 44 | ```PowerShell 45 | @('CPUID.CPU-Z','7zip.7zip') | ForEach-Object { Install-WinGetPackage $_ } 46 | ``` 47 | 48 | ### Install a specific version of a package 49 | ```PowerShell 50 | Install-WinGetPackage CPUID.CPU-Z -Version 1.95 51 | ``` 52 | 53 | ### Install multiple packages with specific versions 54 | ```PowerShell 55 | @( 56 | @{ 57 | id = 'CPUID.CPU-Z' 58 | version = '1.95' 59 | }, 60 | @{ 61 | id = 'putty.putty' 62 | version = '0.74' 63 | } 64 | ) | Install-WinGetPackage 65 | ``` 66 | 67 | ### Get list of installed packages 68 | ```PowerShell 69 | Get-WinGetPackage nodejs 70 | ``` 71 | 72 | ### Get list of installed packages that can be upgraded 73 | ```PowerShell 74 | Get-WinGetPackageUpdate 75 | ``` 76 | 77 | ### Upgrade a package 78 | ```PowerShell 79 | Update-WinGetPackage CPUID.CPU-Z 80 | ``` 81 | 82 | ### Upgrade a list of packages 83 | ```PowerShell 84 | @('CPUID.CPU-Z','7zip.7zip') | ForEach-Object { Update-WinGetPackage -ID $_ } 85 | ``` 86 | 87 | ### Upgrade all packages 88 | > :warning: **Use at your own risk!** WinGet will try to upgrade all layered software it finds, may not always succeed, and may upgrade software you don't want upgraded. 89 | ```PowerShell 90 | Update-WinGetPackage -All 91 | ``` 92 | 93 | ### Uninstall a package 94 | ```PowerShell 95 | Get-WinGetPackage nodejs | Uninstall-WinGetPackage 96 | 97 | Uninstall-WinGetPackage 7zip.7zip 98 | ``` 99 | 100 | ### Manage package sources 101 | ```PowerShell 102 | Register-WinGetSource privateRepo -Argument 'https://somewhere/out/there/api/v2/' 103 | Find-WinGetPackage nodejs -Source privateRepo -Exact | Install-WinGetPackage 104 | Unregister-WinGetSource privateRepo 105 | ``` 106 | 107 | Cobalt integrates with WinGet.exe to manage and store source information 108 | 109 | ## Known Issues 110 | ### Stability 111 | WinGet's behavior and APIs are still very unstable. Do not be surprised if this module stops working with newer versions of WinGet. 112 | 113 | ### Retrieving package upgrade list hangs on first use 114 | Due to [a bug](https://github.com/microsoft/winget-cli/issues/1869) that [is resolved](https://github.com/microsoft/winget-cli/pull/1874) in WinGet v1.3 preview releases, if `Get-WinGetPackageUpdate` is ran with WinGet v1.2.x or below without having first accepted source license agreements, the cmdlet will hang indefinitely due to source agreements not having been accepted. 115 | 116 | Available workarounds include: 117 | * Running another cmdlet that invokes correctly-behaving WinGet behavior (ex: `Get-WinGetPackage`) 118 | * Manually accepting the source agreement via the WinGet CLI 119 | 120 | After WinGet v1.3 is generally available with the bug fixed, a new version of this module will be released to resolve this issue. 121 | 122 | ## Legal and Licensing 123 | Cobalt is licensed under the [MIT license](./LICENSE.txt). 124 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | . (Join-Path -Path src -ChildPath Cobalt.ps1 -Resolve) 2 | 3 | $commandArray = @() 4 | 5 | $commands | ForEach-Object { 6 | $Noun = $_.Noun 7 | # Inherit noun-level attributes (if they exist) for all commands 8 | # If no noun-level original command elements or parameters exist, return an empty array for easy merging later 9 | $NounOriginalCommandElements = $_.OriginalCommandElements ?? @() 10 | $NounParameters = $_.Parameters ?? @() 11 | # Output handlers work differently - they will supercede each other, instead of being merged. 12 | $NounOutputHandlers = $_.OutputHandlers 13 | $NounDefaultParameterSetName = $_.DefaultParameterSetName 14 | $_.Verbs | ForEach-Object { 15 | # Same logic as nouns - prepare verb-level original command elements and parameters for merging, but not output handlers 16 | $VerbOriginalCommandElements = $_.OriginalCommandElements ?? @() 17 | $VerbParameters = $_.Parameters ?? @() 18 | $VerbOutputHandlers = $_.OutputHandlers 19 | $Description = $_.Description 20 | $VerbDefaultParameterSetName = $_.DefaultParameterSetName 21 | $commandArray += $(New-CrescendoCommand -Verb $_.Verb -Noun $Noun -OriginalName $BaseOriginalName | ForEach-Object { 22 | # Marge command elements in order of noun-level first, then verb-level, then generic 23 | $_.OriginalCommandElements = ($NounOriginalCommandElements + $VerbOriginalCommandElements + $BaseOriginalCommandElements) 24 | $_.Description = $Description 25 | # Merge parameters in order of noun-level, then verb-level, then generic 26 | $_.Parameters = ($NounParameters + $VerbParameters + $BaseParameters) 27 | # Prefer verb-level default parameter set name first, then noun-level, then generic 28 | $_.DefaultParameterSetName = ($VerbDefaultParameterSetName ?? $NounDefaultParameterSetName) ?? $BaseDefaultParameterSetName 29 | # Prefer verb-level handlers first, then noun-level, then generic 30 | $_.OutputHandlers = ($VerbOutputHandlers ?? $NounOutputHandlers) ?? $BaseOutputHandlers 31 | $_ 32 | }) 33 | } 34 | } 35 | 36 | $tempJson = (New-TemporaryFile).FullName 37 | Export-CrescendoCommand -command $commandArray -fileName $tempJson -Force 38 | Export-CrescendoModule -NoClobberManifest -ConfigurationFile $tempJson -ModuleName (Join-Path -Path src -ChildPath Cobalt.psm1) -Force 39 | -------------------------------------------------------------------------------- /src/Cobalt.ps1: -------------------------------------------------------------------------------- 1 | $BaseOriginalName = 'WinGet' 2 | 3 | $BaseOriginalCommandElements = @() 4 | 5 | $BaseParameters = @() 6 | 7 | $BaseOutputHandlers = @{ 8 | ParameterSetName = 'Default' 9 | Handler = { 10 | param ( $output ) 11 | } 12 | } 13 | 14 | $outputHanderHeader = {param ($output)} 15 | 16 | $i18nHandlerHelper = { 17 | $language = (Get-UICulture).Name 18 | 19 | $languageData = $( 20 | $hash = @{} 21 | 22 | $(try { 23 | # We have to trim the leading BOM for .NET's XML parser to correctly read Microsoft's own files - go figure 24 | ([xml](((Invoke-WebRequest -Uri "https://raw.githubusercontent.com/microsoft/winget-cli/v1.3.2691/Localization/Resources/$language/winget.resw" -ErrorAction Stop ).Content -replace "\uFEFF", ""))).root.data 25 | } catch { 26 | # Fall back to English if a locale file doesn't exist 27 | ( 28 | ('SearchName','Name'), 29 | ('SearchID','Id'), 30 | ('SearchVersion','Version'), 31 | ('AvailableHeader','Available'), 32 | ('SearchSource','Source'), 33 | ('ShowVersion','Version'), 34 | ('GetManifestResultVersionNotFound','No version found matching:'), 35 | ('InstallerFailedWithCode','Installer failed with exit code:'), 36 | ('UninstallFailedWithCode','Uninstall failed with exit code:'), 37 | ('AvailableUpgrades','upgrades available.') 38 | ) | ForEach-Object {[pscustomobject]@{name = $_[0]; value = $_[1]}} 39 | }) | ForEach-Object { 40 | # Convert the array into a hashtable 41 | $hash[$_.name] = $_.value 42 | } 43 | 44 | $hash 45 | ) 46 | } 47 | 48 | $GetPackageOutputHandler = { 49 | $nameHeader = $output -Match "^$($languageData.SearchName)" 50 | 51 | if ($nameHeader) { 52 | 53 | $headerLine = $output.IndexOf(($nameHeader | Select-Object -First 1)) 54 | 55 | if ($headerLine -ne -1) { 56 | $idIndex = $output[$headerLine].IndexOf(($languageData.SearchID)) 57 | $versionIndex = $output[$headerLine].IndexOf(($languageData.SearchVersion)) 58 | $availableIndex = $output[$headerLine].IndexOf(($languageData.AvailableHeader)) 59 | $sourceIndex = $output[$headerLine].IndexOf(($languageData.SearchSource)) 60 | 61 | # Stop gathering version data at the 'Available' column if it exists, if not continue on to the 'Source' column (if it exists) 62 | $versionEndIndex = $( 63 | if ($availableIndex -ne -1) { 64 | $availableIndex 65 | } else { 66 | $sourceIndex 67 | } 68 | ) 69 | 70 | # Only attempt to parse output if it contains a 'version' column 71 | if ($versionIndex -ne -1) { 72 | # The -replace cleans up errant characters that come from WinGet's poor treatment of truncated columnar output 73 | ($output | Select-String -Pattern $languageData.AvailableUpgrades,'--include-unknown' -NotMatch) -replace '[^i\p{IsBasicLatin}]+',' ' | Select-Object -Skip ($headerLine+2) | ForEach-Object { 74 | Remove-Variable -Name 'package' -ErrorAction SilentlyContinue 75 | 76 | $package = [ordered]@{ 77 | ID = $_.SubString($idIndex,$versionIndex-$idIndex).Trim() 78 | } 79 | 80 | if ($package) { 81 | # I'm so sorry, blame WinGet 82 | # If neither the 'Available' or 'Source' column exist, gather version data to the end of the string 83 | $package.Version = $( 84 | if ($versionEndIndex -ne -1) { 85 | $_.SubString($versionIndex,$versionEndIndex-$versionIndex) 86 | } else { 87 | $_.SubString($versionIndex) 88 | } 89 | ).Trim() -replace '[^\.\d]' 90 | 91 | # Only attempt to add 'Available Version' data if the column exists 92 | if ($availableIndex -ne -1) { 93 | $package.Available = $( 94 | if ($sourceIndex -ne -1) { 95 | $_.SubString($availableIndex,$sourceIndex-$availableIndex) 96 | } else { 97 | $_.SubString($availableIndex) 98 | } 99 | ).Trim() -replace '[^\.\d]' 100 | } 101 | 102 | # If the 'Source' column was included in the output, include it in our output, too 103 | if (($sourceIndex -ne -1) -And ($_.Length -ge $sourceIndex)) { 104 | $package.Source = $_.SubString($sourceIndex).Trim() -split ' ' | Select-Object -Last 1 105 | } 106 | 107 | [pscustomobject]$package 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | $InstallPackageOutputHandler = { 116 | if ($output) { 117 | if ($output -match $languageData.InstallerFailedWithCode) { 118 | # Only show output that matches or comes after the 'failed' keyword 119 | Write-Error ($output[$output.IndexOf($($output -match $languageData.InstallerFailedWithCode | Select-Object -First 1))..($output.Length-1)] -join "`r`n") 120 | } else { 121 | $output | ForEach-Object { 122 | if ($_ -match 'Found .+ \[(?[\S]+)\] Version (?[\S]+)' -and $Matches.id -and $Matches.version) { 123 | [pscustomobject]@{ 124 | ID = $Matches.id 125 | Version = $Matches.version 126 | } 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | $UnInstallPackageOutputHandler = { 134 | if ($output) { 135 | if ($output -match $languageData.UninstallFailedWithCode) { 136 | # Only show output that matches or comes after the 'failed' keyword 137 | Write-Error ($output[$output.IndexOf($($output -match $languageData.UninstallFailedWithCode | Select-Object -First 1))..($output.Length-1)] -join "`r`n") 138 | } 139 | } 140 | } 141 | 142 | $PackageInfoVersionOutputHandler = { 143 | if ($output) { 144 | if ($output | Select-String -Pattern $languageData.GetManifestResultVersionNotFound) { 145 | # Only show output that matches or comes after the 'failed' keyword 146 | Write-Error ($output[$output.IndexOf($($output | Select-String -Pattern $languageData.GetManifestResultVersionNotFound | Select-Object -First 1))..($output.Length-1)] -join "`r`n") 147 | } else { 148 | $versionHeader = $output -Match "^$($languageData.ShowVersion)" 149 | 150 | if ($versionHeader) { 151 | 152 | $headerLine = $output.IndexOf(($versionHeader | Select-Object -First 1)) 153 | 154 | if ($headerLine -ne -1) { 155 | $output | Select-Object -Skip ($headerLine+2) 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | $RenderedGetPackageOutputHandler = [scriptblock]::Create($outputHanderHeader.ToString() + $i18nHandlerHelper.ToString() + $GetPackageOutputHandler.ToString()) 163 | $RenderedInstallPackageOutputHandler = [scriptblock]::Create($outputHanderHeader.ToString() + $i18nHandlerHelper.ToString() + $InstallPackageOutputHandler.ToString()) 164 | $RenderedUnInstallPackageOutputHandler = [scriptblock]::Create($outputHanderHeader.ToString() + $i18nHandlerHelper.ToString() + $UnInstallPackageOutputHandler.ToString()) 165 | $RenderedPackageInfoVersionOutputHandler = [scriptblock]::Create($outputHanderHeader.ToString() + $i18nHandlerHelper.ToString() + $PackageInfoVersionOutputHandler.ToString()) 166 | 167 | # The general structure of this hashtable is to define noun-level attributes, which are -probably- common across all commands for the same noun, but still allow for customization at more specific verb-level defition for that noun. 168 | # The following three command attributes have the following order of precedence: 169 | # OriginalCommandElements will be MERGED in the order of Noun + Verb + Base 170 | # Example: Noun WinGetSource's element 'source', Verb Register's element 'add', and Base elements are merged to become 'WinGet source add --limit-output --yes' 171 | # Parameters will be MERGED in the order of Noun + Verb + Base 172 | # Example: Noun WinGetPackage's parameters for package name and version and Verb Install's parameter specifying source information are merged to become ' --version= --source='. 173 | # These are then appended to the merged original command elements, to create 'WinGet install --version= --source= --limit-output --yes' 174 | # OutputHandler sets will SUPERCEDE each other in the order of: Verb -beats-> Noun -beats-> Base. This allows reusability of PowerShell parsing code. 175 | # Example: Noun WinGetPackage has inline output handler PowerShell code with complex regex that works for both Install-WinGetPackage and Uninstall-WinGetPackage, but Get-WinGetPackage's native output uses simple vertical bar delimiters. 176 | # Example 2: The native commands for Register-WinGetSource and Unregister-WinGetSource don't return any output, and until Crescendo supports error handling by exit codes, a base required default output handler that doesn't do anything can be defined and reused in multiple places. 177 | $Commands = @( 178 | @{ 179 | Noun = 'WinGetSource' 180 | OriginalCommandElements = @('source') 181 | Verbs = @( 182 | @{ 183 | Verb = 'Get' 184 | Description = 'Return WinGet package sources' 185 | OriginalCommandElements = @('export') 186 | Parameters = @( 187 | @{ 188 | Name = 'Name' 189 | ParameterType = 'string' 190 | Description = 'Source Name' 191 | OriginalName = '--name=' 192 | NoGap = $true 193 | } 194 | ) 195 | OutputHandlers = @{ 196 | ParameterSetName = 'Default' 197 | Handler = { 198 | param ($output) 199 | if ($output) { 200 | $output | ConvertFrom-Json 201 | } 202 | } 203 | } 204 | }, 205 | @{ 206 | Verb = 'Register' 207 | Description = 'Register a new WinGet package source' 208 | OriginalCommandElements = @('add') 209 | Parameters = @( 210 | @{ 211 | Name = 'Name' 212 | ParameterType = 'string' 213 | Description = 'Source Name' 214 | OriginalName = '--name=' 215 | NoGap = $true 216 | Mandatory = $true 217 | }, 218 | @{ 219 | Name = 'Argument' 220 | OriginalName = '--arg=' 221 | ParameterType = 'string' 222 | Description = 'Source Argument' 223 | NoGap = $true 224 | Mandatory = $true 225 | } 226 | ) 227 | OutputHandlers = @{ 228 | ParameterSetName = 'Default' 229 | Handler = { 230 | param ($output) 231 | if ($output) { 232 | if ($output[-1] -ne 'Done') { 233 | Write-Error ($output -join "`r`n") 234 | } 235 | } 236 | } 237 | } 238 | }, 239 | @{ 240 | Verb = 'Unregister' 241 | Description = 'Unegister an existing WinGet package source' 242 | OriginalCommandElements = @('remove') 243 | Parameters = @( 244 | @{ 245 | Name = 'Name' 246 | ParameterType = 'string' 247 | Description = 'Source Name' 248 | OriginalName = '--name=' 249 | NoGap = $true 250 | Mandatory = $true 251 | ValueFromPipelineByPropertyName = $true 252 | } 253 | ) 254 | OutputHandlers = @{ 255 | ParameterSetName = 'Default' 256 | Handler = { 257 | param ($output) 258 | if ($output) { 259 | if ($output[-1] -match 'Did not find a source') { 260 | Write-Error ($output -join "`r`n") 261 | } 262 | } 263 | } 264 | } 265 | } 266 | ) 267 | }, 268 | @{ 269 | Noun = 'WinGetPackage' 270 | Parameters = @( 271 | @{ 272 | Name = 'ID' 273 | OriginalName = '--id=' 274 | ParameterType = 'string' 275 | Description = 'Package ID' 276 | NoGap = $true 277 | ValueFromPipelineByPropertyName = $true 278 | }, 279 | @{ 280 | Name = 'Exact' 281 | OriginalName = '--exact' 282 | ParameterType = 'switch' 283 | Description = 'Search by exact package name' 284 | }, 285 | @{ 286 | Name = 'Source' 287 | OriginalName = '--source=' 288 | ParameterType = 'string' 289 | Description = 'Package Source' 290 | NoGap = $true 291 | ValueFromPipelineByPropertyName = $true 292 | } 293 | ) 294 | OutputHandlers = @{ 295 | ParameterSetName = 'Default' 296 | Handler = $RenderedGetPackageOutputHandler 297 | } 298 | Verbs = @( 299 | @{ 300 | Verb = 'Install' 301 | Description = 'Install a new package with WinGet' 302 | OriginalCommandElements = @('install','--accept-package-agreements','--accept-source-agreements','--silent') 303 | Parameters = @( 304 | @{ 305 | Name = 'Version' 306 | OriginalName = '--version=' 307 | ParameterType = 'string' 308 | Description = 'Package Version' 309 | NoGap = $true 310 | ValueFromPipelineByPropertyName = $true 311 | } 312 | ) 313 | OutputHandlers = @{ 314 | ParameterSetName = 'Default' 315 | Handler = $RenderedInstallPackageOutputHandler 316 | } 317 | }, 318 | @{ 319 | Verb = 'Get' 320 | Description = 'Get a list of installed WinGet packages' 321 | OriginalCommandElements = @('list','--accept-source-agreements') 322 | }, 323 | @{ 324 | Verb = 'Find' 325 | Description = 'Find a list of available WinGet packages' 326 | OriginalCommandElements = @('search','--accept-source-agreements') 327 | }, 328 | @{ 329 | Verb = 'Update' 330 | Description = 'Updates an installed package to the latest version' 331 | OriginalCommandElements = @('upgrade','--accept-source-agreements','--silent') 332 | Parameters = @( 333 | @{ 334 | Name = 'All' 335 | OriginalName = '--all' 336 | ParameterType = 'switch' 337 | Description = 'Upgrade all packages' 338 | } 339 | ) 340 | OutputHandlers = @{ 341 | ParameterSetName = 'Default' 342 | Handler = $RenderedInstallPackageOutputHandler 343 | } 344 | }, 345 | @{ 346 | Verb = 'Uninstall' 347 | Description = 'Uninstall an existing package with WinGet' 348 | OriginalCommandElements = @('uninstall','--accept-source-agreements','--silent') 349 | OutputHandlers = @{ 350 | ParameterSetName = 'Default' 351 | Handler = $RenderedUnInstallPackageOutputHandler 352 | } 353 | } 354 | ) 355 | }, 356 | @{ 357 | Noun = 'WinGetPackageInfo' 358 | Verbs = @( 359 | @{ 360 | Verb = 'Get' 361 | Description = 'Shows information on a specific WinGet package' 362 | OriginalCommandElements = @('show','--accept-source-agreements') 363 | DefaultParameterSetName = 'Default' 364 | Parameters = @( 365 | @{ 366 | Name = 'ID' 367 | OriginalName = '--id=' 368 | ParameterType = 'string' 369 | Description = 'Package ID' 370 | NoGap = $true 371 | Mandatory = $true 372 | ValueFromPipelineByPropertyName = $true 373 | Position = 0 374 | ParameterSetName = @('Default','Versions') 375 | }, 376 | @{ 377 | Name = 'Exact' 378 | OriginalName = '--exact' 379 | ParameterType = 'switch' 380 | Description = 'Search by exact package name' 381 | ParameterSetName = @('Default','Versions') 382 | }, 383 | @{ 384 | Name = 'Version' 385 | OriginalName = '--version=' 386 | ParameterType = 'string' 387 | Description = 'Package Version' 388 | NoGap = $true 389 | ValueFromPipelineByPropertyName = $true 390 | ParameterSetName = @('Default','Versions') 391 | }, 392 | @{ 393 | Name = 'Source' 394 | OriginalName = '--source=' 395 | ParameterType = 'string' 396 | Description = 'Package Source' 397 | NoGap = $true 398 | ValueFromPipelineByPropertyName = $true 399 | ParameterSetName = @('Default','Versions') 400 | }, 401 | @{ 402 | Name = 'Versions' 403 | OriginalName = '--versions' 404 | ParameterType = 'switch' 405 | Description = 'Show available versions of the package' 406 | ParameterSetName = 'Versions' 407 | } 408 | ) 409 | OutputHandlers = @( 410 | @{ 411 | ParameterSetName = 'Default' 412 | Handler = { 413 | param ( $output ) 414 | 415 | $packageInfo = @{} 416 | 417 | $output | Select-String -AllMatches -Pattern '^\s*([\w\s]+):\s(.+)$' | ForEach-Object -MemberName Matches | ForEach-Object{ 418 | $match = ($_.Groups | Select-Object -Skip 1).Value 419 | $packageInfo.add($match[0],$match[1]) 420 | } 421 | 422 | $packageInfo 423 | } 424 | }, 425 | @{ 426 | ParameterSetName = 'Versions' 427 | Handler = $RenderedPackageInfoVersionOutputHandler 428 | } 429 | ) 430 | } 431 | ) 432 | }, 433 | @{ 434 | Noun = 'WinGetPackageUpdate' 435 | OutputHandlers = @{ 436 | ParameterSetName = 'Default' 437 | Handler = $RenderedGetPackageOutputHandler 438 | } 439 | Verbs = @( 440 | @{ 441 | Verb = 'Get' 442 | Description = 'Get a list of installed WinGet packages' 443 | # Add this back in after WinGet 1.3 is released 444 | # https://github.com/microsoft/winget-cli/issues/1869 445 | # https://github.com/microsoft/winget-cli/pull/1874 446 | # OriginalCommandElements = @('upgrade','--accept-source-agreements') 447 | OriginalCommandElements = @('upgrade') 448 | } 449 | ) 450 | } 451 | ) 452 | -------------------------------------------------------------------------------- /src/Cobalt.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | RootModule = 'Cobalt.psm1' 3 | ModuleVersion = '0.4.0' 4 | GUID = '9f295092-e7fd-4c52-b41e-3c5b0612fa52' 5 | Author = 'Ethan Bergstrom' 6 | Copyright = '2021' 7 | Description = 'A PowerShell Crescendo wrapper for WinGet' 8 | # Crescendo modules aren't supported below PowerShell 5.1 9 | # https://devblogs.microsoft.com/powershell/announcing-powershell-crescendo-preview-1/ 10 | PowerShellVersion = '5.1' 11 | PrivateData = @{ 12 | PSData = @{ 13 | # Tags applied to this module to indicate this is a PackageManagement Provider. 14 | Tags = @('Crescendo','WinGet','PSEdition_Desktop','PSEdition_Core','Windows','CrescendoBuilt') 15 | 16 | # A URL to the license for this module. 17 | LicenseUri = 'https://github.com/ethanbergstrom/Cobalt/blob/main/LICENSE.txt' 18 | 19 | # A URL to the main website for this project. 20 | ProjectUri = 'https://github.com/ethanbergstrom/Cobalt' 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/Cobalt.tests.ps1: -------------------------------------------------------------------------------- 1 | Import-Module Cobalt 2 | 3 | Describe 'basic package search operations' { 4 | Context 'without additional arguments' { 5 | BeforeAll { 6 | $package = 'Microsoft.PowerShell' 7 | } 8 | 9 | It 'gets a list of latest installed packages' { 10 | Get-WinGetPackage | Where-Object {$_.Source -eq 'winget'} | Should -Not -BeNullOrEmpty 11 | } 12 | It 'searches for the latest version of a package' { 13 | Find-WinGetPackage -ID $package -Exact | Where-Object {$_.ID -eq $package} | Should -Not -BeNullOrEmpty 14 | } 15 | } 16 | } 17 | 18 | Describe 'DSC-compliant package installation and uninstallation' { 19 | Context 'without additional arguments' { 20 | BeforeAll { 21 | $package = 'CPUID.CPU-Z' 22 | } 23 | 24 | It 'searches for the latest version of a package' { 25 | Find-WinGetPackage -ID $package -Exact | Where-Object {$_.ID -eq $package} | Should -Not -BeNullOrEmpty 26 | } 27 | It 'silently installs the latest version of a package' { 28 | Install-WinGetPackage -ID $package -Exact | Where-Object {$_.ID -eq $package} | Should -Not -BeNullOrEmpty 29 | } 30 | It 'finds the locally installed package just installed' { 31 | Get-WinGetPackage -ID $package | Where-Object {$_.ID -eq $package} | Should -Not -BeNullOrEmpty 32 | } 33 | It 'silently uninstalls the locally installed package just installed' { 34 | {Uninstall-WinGetPackage -ID $package} | Should -Not -Throw 35 | } 36 | } 37 | } 38 | 39 | Describe 'pipline-based package installation and uninstallation' { 40 | Context 'without additional arguments' { 41 | BeforeAll { 42 | $package = 'CPUID.CPU-Z' 43 | } 44 | 45 | It 'searches for and silently installs the latest version of a package' { 46 | Find-WinGetPackage -ID $package -Exact | Install-WinGetPackage | Where-Object {$_.ID -eq $package} | Should -Not -BeNullOrEmpty 47 | } 48 | It 'finds and silently uninstalls the locally installed package just installed' { 49 | {Get-WinGetPackage -ID $package | Uninstall-WinGetPackage} | Should -Not -Throw 50 | } 51 | } 52 | } 53 | 54 | Describe 'package version handling' { 55 | Context 'a package with a long name' { 56 | BeforeAll { 57 | # Winget columnar output introduces strange characters in termainal output when package name exceeds 41 characters. 58 | # The full name of this package in Winget is 'Microsoft Visual C++ 2013 Redistributable (x64)', which is 47 characters and is already installed on GitHub Action's runners by default 59 | # https://github.com/actions/runner-images/blob/main/images/win/Windows2022-Readme.md#microsoft-visual-c 60 | $package = 'Microsoft.VCRedist.2013.x64' 61 | # VC2013 packages are always numbered 12.x 62 | $majorVersion = 12 63 | } 64 | 65 | It 'properly parses version information returned by Winget' { 66 | Get-WinGetPackage -ID $package | Where-Object {([version]$_.Version).Major -eq $majorVersion} | Should -Not -BeNullOrEmpty 67 | } 68 | } 69 | } 70 | 71 | Describe 'package upgrade' { 72 | Context 'a single package' { 73 | BeforeAll { 74 | $package = 'CPUID.CPU-Z' 75 | $version = '1.95' 76 | Install-WinGetPackage -ID $package -Version $version -Exact 77 | } 78 | AfterAll { 79 | Uninstall-WinGetPackage -ID $package 80 | } 81 | 82 | It 'recognizes a package upgrade is available' { 83 | Get-WinGetPackageUpdate | Where-Object {$_.ID -eq $package} | Where-Object {[version]$_.available -gt [version]$version} | Should -Not -BeNullOrEmpty 84 | } 85 | It 'upgrades a specific package to the latest version' { 86 | Update-WinGetPackage -ID $package -Exact | Where-Object {$_.ID -eq $package} | Where-Object {[version]$_.version -gt [version]$version} | Should -Not -BeNullOrEmpty 87 | } 88 | It 'upgrades again, and returns no output, because everything is up to date' { 89 | Update-WinGetPackage -ID $package -Exact | Where-Object {$_.ID -eq $package} | Where-Object {[version]$_.version -gt [version]$version} | Should -BeNullOrEmpty 90 | } 91 | } 92 | } 93 | 94 | Describe 'WinGet error handling' { 95 | Context 'no results returned' { 96 | BeforeAll { 97 | $package = 'Cisco.*' 98 | } 99 | 100 | It 'searches for an ID that will never exist' { 101 | {Find-WinGetPackage -ID $package} | Should -Not -Throw 102 | } 103 | It 'searches for an ID that will never exist' { 104 | {Get-WinGetPackage -ID $package} | Should -Not -Throw 105 | } 106 | } 107 | } 108 | 109 | Describe 'package metadata retrieval' { 110 | Context 'package details' { 111 | BeforeAll { 112 | $package = 'Mozilla.Firefox' 113 | $version = '98.0' 114 | } 115 | 116 | It 'returns package metadata' { 117 | Get-WinGetPackageInfo -ID $package -Version $version -Exact | Where-Object {$_.Version -eq $version} | Should -Not -BeNullOrEmpty 118 | } 119 | It 'returns package versions' { 120 | (Get-WinGetPackageInfo -ID $package -Versions -Exact).Contains($version) | Should -Be $true 121 | } 122 | } 123 | } 124 | --------------------------------------------------------------------------------