<# .SYNOPSIS WinGet Manifest creation helper script .DESCRIPTION This file intends to help you generate a manifest for publishing to the Windows Package Manager repository. It'll attempt to download an installer from the user-provided URL to calculate a checksum. That checksum and the rest of the input data will be compiled into a set of .YAML files. .EXAMPLE PS C:\Projects\winget-pkgs> Get-Help .\Tools\YamlCreate.ps1 -Full Show this script's help .EXAMPLE PS C:\Projects\winget-pkgs> .\Tools\YamlCreate.ps1 Run the script to create a manifest file .NOTES Please file an issue if you run into errors with this script: https://github.com/microsoft/winget-pkgs/issues .LINK https://github.com/microsoft/winget-pkgs/blob/master/Tools/YamlCreate.ps1 #> #Requires -Version 5 [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'This script is not intended to have any outputs piped')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Preserve', Justification = 'The variable is used in a conditional but ScriptAnalyser does not recognize the scope')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Scope = 'Function', Target = 'Read-AppsAndFeaturesEntries', Justification = 'Ths function is a wrapper which calls the singular Read-AppsAndFeaturesEntry as many times as necessary. It corresponds exactly to a pluralized manifest field')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Scope = 'Function', Target = '*Metadata', Justification = 'Metadata is used as a mass noun and is therefore singular in the cases used in this script')] Param ( [switch] $Settings, [switch] $AutoUpgrade, [switch] $help, [switch] $SkipPRCheck, [switch] $Preserve, [Parameter(Mandatory = $false)] [string] $PackageIdentifier, [Parameter(Mandatory = $false)] [string] $PackageVersion, [Parameter(Mandatory = $false)] [string] $Mode ) $ProgressPreference = 'SilentlyContinue' if ($help) { Write-Host -ForegroundColor 'Green' 'For full documentation of the script, see https://github.com/microsoft/winget-pkgs/tree/master/doc/tools/YamlCreate.md' Write-Host -ForegroundColor 'Yellow' 'Usage: ' -NoNewline Write-Host -ForegroundColor 'White' '.\YamlCreate.ps1 [-PackageIdentifier ] [-PackageVersion ] [-Mode <1-5>] [-Settings] [-SkipPRCheck]' Write-Host exit } # Custom menu prompt that listens for key presses. Requires a prompt and array of entries at minimum. Entries preceeded with `*` are shown in green # Returns a console key value Function Invoke-KeypressMenu { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $Prompt, [Parameter(Mandatory = $true, Position = 1)] [string[]] $Entries, [Parameter(Mandatory = $false)] [string] $HelpText, [Parameter(Mandatory = $false)] [string] $HelpTextColor, [Parameter(Mandatory = $false)] [string] $DefaultString, [Parameter(Mandatory = $false)] [string[]] $AllowedCharacters ) if (!$PSBoundParameters.ContainsKey('AllowedCharacters')) { $AllowedCharacters = @($Entries.TrimStart('*').Chars(1)) } Write-Host "`n" Write-Host -ForegroundColor 'Yellow' "$Prompt" if ($PSBoundParameters.ContainsKey('HelpText') -and (![string]::IsNullOrWhiteSpace($HelpText))) { if ($PSBoundParameters.ContainsKey('HelpTextColor') -and (![string]::IsNullOrWhiteSpace($HelpTextColor))) { Write-Host -ForegroundColor $HelpTextColor $HelpText } else { Write-Host -ForegroundColor 'Blue' $HelpText } } foreach ($entry in $Entries) { $_isDefault = $entry.StartsWith('*') if ($_isDefault) { $_entry = ' ' + $entry.Substring(1) $_color = 'Green' } else { $_entry = ' ' + $entry $_color = 'White' } Write-Host -ForegroundColor $_color $_entry } Write-Host if ($PSBoundParameters.ContainsKey('DefaultString') -and (![string]::IsNullOrWhiteSpace($DefaultString))) { Write-Host -NoNewline "Enter Choice (default is '$DefaultString'): " } else { Write-Host -NoNewline 'Enter Choice (' Write-Host -NoNewline -ForegroundColor 'Green' 'Green' Write-Host -NoNewline ' is default): ' } do { $keyInfo = [Console]::ReadKey($false) if ($keyInfo.KeyChar -notin $AllowedCharacters -and $ScriptSettings.ExplicitMenuOptions -eq $true -and $AllowedCharacters.Length -gt 0) { if ($keyInfo.Key -eq 'Enter') { Write-Host } $keyInfo = $null } } until ($keyInfo.Key) return $keyInfo.Key } #If the user has git installed, make sure it is a patched version if (Get-Command 'git' -ErrorAction SilentlyContinue) { $GitMinimumVersion = [System.Version]::Parse('2.39.1') $gitVersionString = ((git version) | Select-String '([0-9]{1,}\.?){3,}').Matches.Value.Trim(' ', '.') $gitVersion = [System.Version]::Parse($gitVersionString) if ($gitVersion -lt $GitMinimumVersion) { # Prompt user to install git if (Get-Command 'winget' -ErrorAction SilentlyContinue) { $_menu = @{ entries = @('[Y] Upgrade Git'; '*[N] Do not upgrade') Prompt = 'The version of git installed on your machine does not satisfy the requirement of version >= 2.39.1; Would you like to upgrade?' HelpText = "Upgrading will attempt to upgrade git using winget`n" DefaultString = '' } switch (Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText']) { 'Y' { Write-Host try { winget upgrade --id Git.Git --exact } catch { throw [UnmetDependencyException]::new('Git could not be upgraded sucessfully', $_) } finally { $gitVersionString = ((git version) | Select-String '([0-9]{1,}\.?){3,}').Matches.Value.Trim(' ', '.') $gitVersion = [System.Version]::Parse($gitVersionString) if ($gitVersion -lt $GitMinimumVersion) { throw [UnmetDependencyException]::new('Git could not be upgraded sucessfully') } } } default { Write-Host; throw [UnmetDependencyException]::new('The version of git installed on your machine does not satisfy the requirement of version >= 2.39.1') } } } else { throw [UnmetDependencyException]::new('The version of git installed on your machine does not satisfy the requirement of version >= 2.39.1') } } # Check whether the script is present inside a fork/clone of microsoft/winget-pkgs repository try { $script:gitTopLevel = (Resolve-Path $(git rev-parse --show-toplevel)).Path } catch { # If there was an exception, the user isn't in a git repo. Throw a custom exception and pass the original exception as an InternalException throw [UnmetDependencyException]::new('This script must be run from inside a clone of the winget-pkgs repository', $_.Exception) } } #### # Description: Ensures a PowerShell module is installed # Inputs: PowerShell Module Name # Outputs: None #### function Initialize-Module { param ( [Parameter(Mandatory = $true)] [String] $Name, [Parameter(Mandatory = $false)] [String[]] $Cmdlet, [Parameter(Mandatory = $false)] [String[]] $Function ) $NuGetVersion = (Get-PackageProvider).Where({ $_.Name -ceq 'NuGet' }).Version $installedModules = Get-Module -ListAvailable -Name $Name # Ensure NuGet is installed and up to date # If the NuGet Package Provider is not installed, the version will be null, which will satisfy the conditional if ($NuGetVersion -lt $script:NuGetMinimumVersion) { try { Write-Debug 'NuGet Package Provider was not found, it will be installed' # This might fail if the user is not an administrator, so catch the errors Install-PackageProvider -Name NuGet -MinimumVersion $script:NuGetMinimumVersion.ToString() -Force -Scope CurrentUser } catch { Write-Error 'Could not install the NuGet package provider which is required to install script dependencies.' -ErrorAction Continue Write-Error "You may be able to resolve this by running: Install-PackageProvider -Name NuGet -MinimumVersion $($script:NuGetMinimumVersion.ToString())" } } Write-Verbose "Ensuring PowerShell module '$Name' is installed" if ($installedModules) { # If the module is installed, attempt to upgrade it Write-Debug "Found $Name in installed modules" } else { # If the module is not installed, attempt to install it try { Install-Module -Name $Name -Force -Repository PSGallery -Scope CurrentUser } catch { Write-Error "$Name was unable to be installed successfully" } } # Verify the module is installed and present try { if (!(Get-Module -Name $Name)) { $importParameters = @{Name = $Name; Scope = 'Local' } # Force the module to be imported into the local scope to avoid changing the global scope if ($PSBoundParameters.ContainsKey('Cmdlet')) { $importParameters['Cmdlet'] = $Cmdlet } if ($PSBoundParameters.ContainsKey('Function')) { $importParameters['Function'] = $Function } Import-Module @importParameters } } catch { Write-Error "$Name was found in available modules, but could not be imported" } } $script:NuGetMinimumVersion = [System.Version]::Parse('2.8.5.201') Initialize-Module -Name 'powershell-yaml' # Used for parsing YAML files Initialize-Module -Name 'MSI' -Cmdlet @('Get-MSITable'; 'Get-MSIProperty') # Used for fetching MSI Properties Initialize-Module -Name 'NtObjectManager' -Function @('Get-Win32ModuleResource'; 'Get-Win32ModuleManifest') # Used for checking installer type inno # Set settings directory on basis of Operating System $script:SettingsPath = Join-Path $(if ([System.Environment]::OSVersion.Platform -match 'Win') { $env:LOCALAPPDATA } else { $env:HOME + '/.config' } ) -ChildPath 'YamlCreate' # Check for settings directory and create it if none exists if (!(Test-Path $script:SettingsPath)) { New-Item -ItemType 'Directory' -Force -Path $script:SettingsPath | Out-Null } # Check for settings file and create it if none exists $script:SettingsPath = $(Join-Path $script:SettingsPath -ChildPath 'Settings.yaml') if (!(Test-Path $script:SettingsPath)) { '# See https://github.com/microsoft/winget-pkgs/tree/master/doc/tools/YamlCreate.md for a list of available settings' > $script:SettingsPath } # Load settings from file $ScriptSettings = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $script:SettingsPath -Encoding UTF8) -join "`n") if ($Settings) { Invoke-Item -Path $script:SettingsPath exit } $ScriptHeader = '# Created with YamlCreate.ps1 v2.5.0' $ManifestVersion = '1.10.0' $PSDefaultParameterValues = @{ '*:Encoding' = 'UTF8' } $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False $ofs = ', ' $callingUICulture = [Threading.Thread]::CurrentThread.CurrentUICulture $callingCulture = [Threading.Thread]::CurrentThread.CurrentCulture [Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US' [Threading.Thread]::CurrentThread.CurrentCulture = 'en-US' if (-not ([System.Environment]::OSVersion.Platform -match 'Win')) { $env:TEMP = '/tmp/' } $wingetUpstream = 'https://github.com/microsoft/winget-pkgs.git' $RunHash = $(Get-FileHash -InputStream $([IO.MemoryStream]::new([byte[]][char[]]$(Get-Date).Ticks.ToString()))).Hash.Substring(0, 8) $script:UserAgent = 'Microsoft-Delivery-Optimization/10.1' $script:CleanupPaths = @() $script:OriginalPSModulePath = $env:PSModulePath Write-Debug 'Setting up module paths for YamlCreate' Write-Debug "Adding $(Join-Path -Path $PSScriptRoot -ChildPath 'Modules') to PSModulePath" $env:PSModulePath = $env:PSModulePath + ';' + (Join-Path -Path $PSScriptRoot -ChildPath 'Modules') # Add the local modules to the PSModulePath Import-Module -Name 'YamlCreate' -Scope Global -Force -ErrorAction 'Stop' # Parent module that loads the rest of the modules required for the script $_wingetVersion = 1.0.0 $_appInstallerVersion = (Get-AppxPackage Microsoft.DesktopAppInstaller).version if (Get-Command 'winget' -ErrorAction SilentlyContinue) { $_wingetVersion = (winget -v).TrimStart('v') } $script:backupUserAgent = "winget-cli WindowsPackageManager/$_wingetVersion DesktopAppInstaller/Microsoft.DesktopAppInstaller v$_appInstallerVersion" if ($ScriptSettings.EnableDeveloperOptions -eq $true -and $null -ne $ScriptSettings.OverrideManifestVersion) { $script:UsesPrerelease = $ScriptSettings.OverrideManifestVersion -gt $ManifestVersion $ManifestVersion = $ScriptSettings.OverrideManifestVersion } $useDirectSchemaLink = if ($env:GITHUB_ACTIONS -eq $true) { $true } else { (Invoke-WebRequest "https://aka.ms/winget-manifest.version.$ManifestVersion.schema.json" -UseBasicParsing).Content -match '' } $SchemaUrls = @{ version = if ($useDirectSchemaLink) { "https://raw.githubusercontent.com/microsoft/winget-cli/master/schemas/JSON/manifests/v$ManifestVersion/manifest.version.$ManifestVersion.json" } else { "https://aka.ms/winget-manifest.version.$ManifestVersion.schema.json" } defaultLocale = if ($useDirectSchemaLink) { "https://raw.githubusercontent.com/microsoft/winget-cli/master/schemas/JSON/manifests/v$ManifestVersion/manifest.defaultLocale.$ManifestVersion.json" } else { "https://aka.ms/winget-manifest.defaultLocale.$ManifestVersion.schema.json" } locale = if ($useDirectSchemaLink) { "https://raw.githubusercontent.com/microsoft/winget-cli/master/schemas/JSON/manifests/v$ManifestVersion/manifest.locale.$ManifestVersion.json" } else { "https://aka.ms/winget-manifest.locale.$ManifestVersion.schema.json" } installer = if ($useDirectSchemaLink) { "https://raw.githubusercontent.com/microsoft/winget-cli/master/schemas/JSON/manifests/v$ManifestVersion/manifest.installer.$ManifestVersion.json" } else { "https://aka.ms/winget-manifest.installer.$ManifestVersion.schema.json" } } # Fetch Schema data from github for entry validation, key ordering, and automatic commenting try { $LocaleSchema = @(Invoke-WebRequest $SchemaUrls.defaultLocale -UseBasicParsing | ConvertFrom-Json) $LocaleProperties = (ConvertTo-Yaml $LocaleSchema.properties | ConvertFrom-Yaml -Ordered).Keys $VersionSchema = @(Invoke-WebRequest $SchemaUrls.version -UseBasicParsing | ConvertFrom-Json) $VersionProperties = (ConvertTo-Yaml $VersionSchema.properties | ConvertFrom-Yaml -Ordered).Keys $InstallerSchema = @(Invoke-WebRequest $SchemaUrls.installer -UseBasicParsing | ConvertFrom-Json) $InstallerProperties = (ConvertTo-Yaml $InstallerSchema.properties | ConvertFrom-Yaml -Ordered).Keys $InstallerSwitchProperties = (ConvertTo-Yaml $InstallerSchema.definitions.InstallerSwitches.properties | ConvertFrom-Yaml -Ordered).Keys $InstallerEntryProperties = (ConvertTo-Yaml $InstallerSchema.definitions.Installer.properties | ConvertFrom-Yaml -Ordered).Keys $InstallerDependencyProperties = (ConvertTo-Yaml $InstallerSchema.definitions.Dependencies.properties | ConvertFrom-Yaml -Ordered).Keys $AppsAndFeaturesEntryProperties = (ConvertTo-Yaml $InstallerSchema.definitions.AppsAndFeaturesEntry.properties | ConvertFrom-Yaml -Ordered).Keys } catch { # Here we want to pass the exception as an inner exception for debugging if necessary throw [System.Net.WebException]::new('Manifest schemas could not be downloaded. Try running the script again', $_.Exception) } filter TrimString { $_.Trim() } filter RightTrimString { $_.TrimEnd() } filter UniqueItems { [string]$($_.Split(',').Trim() | Select-Object -Unique) } filter ToLower { [string]$_.ToLower() } filter NoWhitespace { [string]$_ -replace '\s{1,}', '-' } $ToNatural = { [regex]::Replace($_, '\d+', { $args[0].Value.PadLeft(20) }) } # Various patterns used in validation to simplify the validation logic $Patterns = @{ PackageIdentifier = $VersionSchema.properties.PackageIdentifier.pattern IdentifierMaxLength = $VersionSchema.properties.PackageIdentifier.maxLength PackageVersion = $InstallerSchema.definitions.PackageVersion.pattern VersionMaxLength = $VersionSchema.properties.PackageVersion.maxLength InstallerSha256 = $InstallerSchema.definitions.Installer.properties.InstallerSha256.pattern InstallerUrl = $InstallerSchema.definitions.Installer.properties.InstallerUrl.pattern InstallerUrlMaxLength = $InstallerSchema.definitions.Installer.properties.InstallerUrl.maxLength ValidArchitectures = $InstallerSchema.definitions.Architecture.enum ValidInstallerTypes = $InstallerSchema.definitions.InstallerType.enum ValidNestedInstallerTypes = $InstallerSchema.definitions.NestedInstallerType.enum SilentSwitchMaxLength = $InstallerSchema.definitions.InstallerSwitches.properties.Silent.maxLength ProgressSwitchMaxLength = $InstallerSchema.definitions.InstallerSwitches.properties.SilentWithProgress.maxLength CustomSwitchMaxLength = $InstallerSchema.definitions.InstallerSwitches.properties.Custom.maxLength SignatureSha256 = $InstallerSchema.definitions.Installer.properties.SignatureSha256.pattern FamilyName = $InstallerSchema.definitions.PackageFamilyName.pattern FamilyNameMaxLength = $InstallerSchema.definitions.PackageFamilyName.maxLength PackageLocale = $LocaleSchema.properties.PackageLocale.pattern InstallerLocaleMaxLength = $InstallerSchema.definitions.Locale.maxLength ProductCodeMinLength = $InstallerSchema.definitions.ProductCode.minLength ProductCodeMaxLength = $InstallerSchema.definitions.ProductCode.maxLength MaxItemsFileExtensions = $InstallerSchema.definitions.FileExtensions.maxItems MaxItemsProtocols = $InstallerSchema.definitions.Protocols.maxItems MaxItemsCommands = $InstallerSchema.definitions.Commands.maxItems MaxItemsSuccessCodes = $InstallerSchema.definitions.InstallerSuccessCodes.maxItems MaxItemsInstallModes = $InstallerSchema.definitions.InstallModes.maxItems PackageLocaleMaxLength = $LocaleSchema.properties.PackageLocale.maxLength PublisherMaxLength = $LocaleSchema.properties.Publisher.maxLength PackageNameMaxLength = $LocaleSchema.properties.PackageName.maxLength MonikerMaxLength = $LocaleSchema.definitions.Tag.maxLength GenericUrl = $LocaleSchema.definitions.Url.pattern GenericUrlMaxLength = $LocaleSchema.definitions.Url.maxLength AuthorMinLength = $LocaleSchema.properties.Author.minLength AuthorMaxLength = $LocaleSchema.properties.Author.maxLength LicenseMaxLength = $LocaleSchema.properties.License.maxLength CopyrightMinLength = $LocaleSchema.properties.Copyright.minLength CopyrightMaxLength = $LocaleSchema.properties.Copyright.maxLength TagsMaxItems = $LocaleSchema.properties.Tags.maxItems ShortDescriptionMaxLength = $LocaleSchema.properties.ShortDescription.maxLength DescriptionMinLength = $LocaleSchema.properties.Description.minLength DescriptionMaxLength = $LocaleSchema.properties.Description.maxLength ValidInstallModes = $InstallerSchema.definitions.InstallModes.items.enum FileExtension = $InstallerSchema.definitions.FileExtensions.items.pattern FileExtensionMaxLength = $InstallerSchema.definitions.FileExtensions.items.maxLength ReleaseNotesMinLength = $LocaleSchema.properties.ReleaseNotes.MinLength ReleaseNotesMaxLength = $LocaleSchema.properties.ReleaseNotes.MaxLength RelativeFilePathMinLength = $InstallerSchema.Definitions.NestedInstallerFiles.items.properties.RelativeFilePath.minLength RelativeFilePathMaxLength = $InstallerSchema.Definitions.NestedInstallerFiles.items.properties.RelativeFilePath.maxLength PortableCommandAliasMinLength = $InstallerSchema.Definitions.NestedInstallerFiles.items.properties.PortableCommandAlias.minLength PortableCommandAliasMaxLength = $InstallerSchema.Definitions.NestedInstallerFiles.items.properties.PortableCommandAlias.maxLength ArchiveInstallerTypes = @('zip') ARP_DisplayNameMinLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.DisplayName.minLength ARP_DisplayNameMaxLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.DisplayName.maxLength ARP_PublisherMinLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.Publisher.minLength ARP_PublisherMaxLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.Publisher.maxLength ARP_DisplayVersionMinLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.DisplayVersion.minLength ARP_DisplayVersionMaxLength = $InstallerSchema.Definitions.AppsAndFeaturesEntry.properties.DisplayVersion.maxLength } # check if upstream exists ($remoteUpstreamUrl = $(git remote get-url upstream)) *> $null if ($remoteUpstreamUrl -and $remoteUpstreamUrl -ne $wingetUpstream) { git remote set-url upstream $wingetUpstream } elseif (!$remoteUpstreamUrl) { Write-Host -ForegroundColor 'Yellow' 'Upstream does not exist. Permanently adding https://github.com/microsoft/winget-pkgs as remote upstream' git remote add upstream $wingetUpstream } #### # Description: Removes files and folders from the file system # Inputs: List of paths to remove # Outputs: None #### function Invoke-FileCleanup { param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [AllowEmptyCollection()] [String[]] $FilePaths ) if (!$FilePaths) { return } foreach ($path in $FilePaths) { Write-Debug "Removing $path" if (Test-Path $path) { Remove-Item -Path $path -Recurse } else { Write-Warning "Could not remove $path as it does not exist" } } } # Since this script changes the UI Calling Culture, a clean exit should set it back to the user preference # If the remote upstream was changed, that should also be set back Function Invoke-CleanExit { if ($remoteUpstreamUrl -and $remoteUpstreamUrl -ne $wingetUpstream) { git remote set-url upstream $remoteUpstreamUrl } Invoke-FileCleanup -FilePaths $script:CleanupPaths Write-Host [Threading.Thread]::CurrentThread.CurrentUICulture = $callingUICulture [Threading.Thread]::CurrentThread.CurrentCulture = $callingCulture $env:PSModulePath = $script:OriginalPSModulePath exit } # This function validates whether a string matches Minimum Length, Maximum Length, and Regex pattern # The switches can be used to specify if null values are allowed regardless of validation Function Test-String { Param ( [Parameter(Mandatory = $true, Position = 0)] [AllowEmptyString()] [string] $InputString, [Parameter(Mandatory = $false)] [regex] $MatchPattern, [Parameter(Mandatory = $false)] [int] $MinLength, [Parameter(Mandatory = $false)] [int] $MaxLength, [switch] $AllowNull, [switch] $NotNull, [switch] $IsNull, [switch] $Not ) $_isValid = $true if ($PSBoundParameters.ContainsKey('MinLength')) { $_isValid = $_isValid -and ($InputString.Length -ge $MinLength) } if ($PSBoundParameters.ContainsKey('MaxLength')) { $_isValid = $_isValid -and ($InputString.Length -le $MaxLength) } if ($PSBoundParameters.ContainsKey('MatchPattern')) { $_isValid = $_isValid -and ($InputString -match $MatchPattern) } if ($AllowNull -and [string]::IsNullOrWhiteSpace($InputString)) { $_isValid = $true } elseif ($NotNull -and [string]::IsNullOrWhiteSpace($InputString)) { $_isValid = $false } if ($IsNull) { $_isValid = [string]::IsNullOrWhiteSpace($InputString) } if ($Not) { return !$_isValid } else { return $_isValid } } # Gets the effective installer type from an installer Function Get-EffectiveInstallerType { Param ( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $Installer ) if ($Installer.Keys -notcontains 'InstallerType') { throw [System.ArgumentException]::new('Invalid Function Parameters. Installer must contain `InstallerType` key') } if ($Installer.InstallerType -notin $Patterns.ArchiveInstallerTypes) { return $Installer.InstallerType } if ($Installer.Keys -notcontains 'NestedInstallerType') { throw [System.ArgumentException]::new("Invalid Function Parameters. Installer type $($Installer.InstallerType) must contain `NestedInstallerType` key") } return $Installer.NestedInstallerType } # Checks a URL and returns the status code received from the URL Function Test-Url { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $URL ) try { $HTTP_Request = [System.Net.WebRequest]::Create($URL) $HTTP_Request.UserAgent = $script:UserAgent $HTTP_Response = $HTTP_Request.GetResponse() $script:ResponseUri = $HTTP_Response.ResponseUri.AbsoluteUri $HTTP_Status = [int]$HTTP_Response.StatusCode } catch { # Failed to download with the Delivery-Optimization User Agent, so try again with the WinINet User Agent try { $HTTP_Request = [System.Net.WebRequest]::Create($URL) $HTTP_Request.UserAgent = $script:backupUserAgent $HTTP_Response = $HTTP_Request.GetResponse() $script:ResponseUri = $HTTP_Response.ResponseUri.AbsoluteUri $HTTP_Status = [int]$HTTP_Response.StatusCode } catch { $HTTP_Status = 404 } } If ($null -eq $HTTP_Response) { $HTTP_Status = 404 } Else { $HTTP_Response.Close() } return $HTTP_Status } # Checks a file name for validity and returns a boolean value Function Test-ValidFileName { param([string]$FileName) $IndexOfInvalidChar = $FileName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) # IndexOfAny() returns the value -1 to indicate no such character was found return $IndexOfInvalidChar -eq -1 } # Prompts user to enter an Installer URL, Tests the URL to ensure it results in a response code of 200, validates it against the manifest schema # Returns the validated URL which was entered Function Request-InstallerUrl { do { Write-Host -ForegroundColor $(if ($script:_returnValue.Severity -gt 1) { 'red' } else { 'yellow' }) $script:_returnValue.ErrorString() if ($script:_returnValue.StatusCode -ne 409) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the download url to the installer.' $NewInstallerUrl = Read-Host -Prompt 'Url' | TrimString } $script:_returnValue = [ReturnValue]::GenericError() if ((Test-Url $NewInstallerUrl) -ne 200) { $script:_returnValue = [ReturnValue]::new(502, 'Invalid URL Response', 'The URL did not return a successful response from the server', 2) } else { if (($script:ResponseUri -ne $NewInstallerUrl) -and ($ScriptSettings.UseRedirectedURL -ne 'never') -and ($NewInstallerUrl -notmatch 'github')) { #If urls don't match, ask to update; If they do update, set custom error and check for validity; $_menu = @{ entries = @('*[Y] Use detected URL'; '[N] Use original URL') Prompt = 'The URL provided appears to be redirected. Would you like to use the destination URL instead?' HelpText = "Discovered URL: $($script:ResponseUri)" DefaultString = 'Y' } switch ($(if ($ScriptSettings.UseRedirectedURL -eq 'always') { 'Y' } else { Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] })) { 'N' { Write-Host -ForegroundColor 'Green' "`nOriginal URL Retained - Proceeding with $NewInstallerUrl`n" } #Continue without replacing URL default { $NewInstallerUrl = $script:ResponseUri $script:_returnValue = [ReturnValue]::new(409, 'URL Changed', 'The URL was changed during processing and will be re-validated', 1) Write-Host } } } $NewInstallerUrl = [System.Web.HttpUtility]::UrlDecode($NewInstallerUrl.Replace('+', '%2B')) $NewInstallerUrl = $NewInstallerUrl.Replace(' ', '%20') if ($script:_returnValue.StatusCode -ne 409) { if (Test-String $NewInstallerUrl -MaxLength $Patterns.InstallerUrlMaxLength -MatchPattern $Patterns.InstallerUrl -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $NewInstallerUrl -MaxLength $Patterns.InstallerUrlMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.InstallerUrlMaxLength) } elseif (Test-String -not $NewInstallerUrl -MatchPattern $Patterns.InstallerUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) return $NewInstallerUrl } Function Get-InstallerFile { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $URI, [Parameter(Mandatory = $true, Position = 1)] [string] $PackageIdentifier, [Parameter(Mandatory = $true, Position = 2)] [string] $PackageVersion ) # Create a filename based on the Package Identifier and Version; Try to get the extension from the URL # If the extension isn't found, use a custom one $_URIPath = $URI.Split('?')[0] $_Filename = "$PackageIdentifier v$PackageVersion - $(Get-Date -f 'yyyy.MM.dd-hh.mm.ss')" + $(if ([System.IO.Path]::HasExtension($_URIPath)) { [System.IO.Path]::GetExtension($_URIPath) } else { '.winget-tmp' }) if (Test-ValidFileName $_Filename) { $_OutFile = Join-Path $env:TEMP -ChildPath $_Filename } else { $_OutFile = (New-TemporaryFile).FullName } # Create a new web client for downloading the file $_WebClient = [System.Net.WebClient]::new() $_WebClient.Headers.Add('User-Agent', $script:UserAgent) # If the system has a default proxy set, use it # Powershell Core will automatically use this, so it's only necessary for PS5 if ($PSVersionTable.PSVersion.Major -lt 6) { $_WebClient.Proxy = [System.Net.WebProxy]::GetDefaultProxy() } # Download the file try { $_WebClient.DownloadFile($URI, $_OutFile) } catch { # Failed to download with the Delivery-Optimization User Agent, so try again with the WinINet User Agent $_WebClient.Headers.Clear() $_WebClient.Headers.Add('User-Agent', $script:backupUserAgent) $_WebClient.DownloadFile($URI, $_OutFile) } finally { # Dispose of the web client to release the resources it uses $_WebClient.Dispose() } return $_OutFile } Function Get-UserSavePreference { switch ($ScriptSettings.SaveToTemporaryFolder) { 'always' { $_Preference = '0' } 'never' { $_Preference = '1' } 'manual' { $_Preference = '2' } default { $_menu = @{ entries = @('[Y] Yes'; '*[N] No'; '[M] Manually Enter SHA256') Prompt = 'Do you want to save the files to the Temp folder?' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $_Preference = '0' } 'N' { $_Preference = '1' } 'M' { $_Preference = '2' } default { $_Preference = '1' } } } } return $_Preference } Function Get-UriArchitecture { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $URI ) if ($URI -match '\b(x|win){0,1}64\b') { return 'x64' } if ($URI -match '\b((win|ia)32)|(x{0,1}86)\b') { return 'x86' } if ($URI -match '\b(arm|aarch)64\b') { return 'arm64' } if ($URI -match '\barm\b') { return 'arm' } return $null } Function Get-UriScope { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $URI ) if ($URI -match '\buser\b') { return 'user' } if ($URI -match '\bmachine\b') { return 'machine' } return $null } function Get-PublisherHash($publisherName) { # Sourced from https://marcinotorowski.com/2021/12/19/calculating-hash-part-of-msix-package-family-name $publisherNameAsUnicode = [System.Text.Encoding]::Unicode.GetBytes($publisherName); $publisherSha256 = [System.Security.Cryptography.HashAlgorithm]::Create('SHA256').ComputeHash($publisherNameAsUnicode); $publisherSha256First8Bytes = $publisherSha256 | Select-Object -First 8; $publisherSha256AsBinary = $publisherSha256First8Bytes | ForEach-Object { [System.Convert]::ToString($_, 2).PadLeft(8, '0') }; $asBinaryStringWithPadding = [System.String]::Concat($publisherSha256AsBinary).PadRight(65, '0'); $encodingTable = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; $result = ''; for ($i = 0; $i -lt $asBinaryStringWithPadding.Length; $i += 5) { $asIndex = [System.Convert]::ToInt32($asBinaryStringWithPadding.Substring($i, 5), 2); $result += $encodingTable[$asIndex]; } return $result.ToLower(); } Function Get-PackageFamilyName { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $FilePath ) if ($FilePath -notmatch '\.(msix|appx)(bundle){0,1}$') { return $null } # Make the downloaded installer a zip file $_MSIX = Get-Item $FilePath $_Zip = Join-Path $_MSIX.Directory.FullName -ChildPath 'MSIX_YamlCreate.zip' $_ZipFolder = [System.IO.Path]::GetDirectoryName($_ZIp) + '\' + [System.IO.Path]::GetFileNameWithoutExtension($_Zip) Copy-Item -Path $_MSIX.FullName -Destination $_Zip # Progress preference has to be set globally for Expand-Archive # https://github.com/PowerShell/Microsoft.PowerShell.Archive/issues/77#issuecomment-601947496 $globalPreference = $global:ProgressPreference $global:ProgressPreference = 'SilentlyContinue' # Expand the zip file to access the manifest inside Expand-Archive $_Zip -DestinationPath $_ZipFolder -Force # Restore the old progress preference $global:ProgressPreference = $globalPreference # Package could be a single package or a bundle, so regex search for either of them $_AppxManifest = Get-ChildItem $_ZipFolder -Recurse -File -Filter '*.xml' | Where-Object { $_.Name -match '^Appx(Bundle)?Manifest.xml$' } | Select-Object -First 1 [XML] $_XMLContent = Get-Content $_AppxManifest.FullName -Raw # The path to the node is different between single package and bundles, this should work to get either $_Identity = @($_XMLContent.Bundle.Identity) + @($_XMLContent.Package.Identity) # Cleanup the files that were created Remove-Item $_Zip -Force Remove-Item $_ZipFolder -Recurse -Force # Return the PFN return $_Identity.Name + '_' + $(Get-PublisherHash $_Identity.Publisher) } # Prompts the user to enter the Package Identifier if it has not been set # Validates that the package identifier matches the schema # Returns the package identifier Function Read-PackageIdentifier { Param( [Parameter(Mandatory = $true, Position = 0)] [AllowEmptyString()] [string] $PackageIdentifier ) $_EnteredIdentifier = $PackageIdentifier do { if ((Test-String $_EnteredIdentifier -IsNull) -or ($script:_returnValue.StatusCode -ne [ReturnValue]::Success().StatusCode)) { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the Package Identifier, in the following format . For example: Microsoft.Excel' $_EnteredIdentifier = Read-Host -Prompt 'PackageIdentifier' | TrimString } $script:PackageIdentifierFolder = $_EnteredIdentifier.Replace('.', '\') if (Test-String $_EnteredIdentifier -MinLength 4 -MaxLength $Patterns.IdentifierMaxLength -MatchPattern $Patterns.PackageIdentifier) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $_EnteredIdentifier -MinLength 4 -MaxLength $Patterns.IdentifierMaxLength) { $script:_returnValue = [ReturnValue]::LengthError(4, $Patterns.IdentifierMaxLength) } elseif (Test-String -not $_EnteredIdentifier -MatchPattern $Patterns.PackageIdentifier) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) return $_EnteredIdentifier } # Prompts the user to enter the details for an archive Installer # Takes the installer as an input # Returns the modified installer Function Read-NestedInstaller { Param( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $_Installer ) if ($_Installer['InstallerType'] -CIn @($Patterns.ArchiveInstallerTypes)) { # Manual Entry of Nested Installer Type with validation if ($_Installer['NestedInstallerType'] -CNotIn @($Patterns.ValidInstallerTypes)) { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the NestedInstallerType. Options:' , @($Patterns.ValidNestedInstallerTypes -join ', ' ) $_Installer['NestedInstallerType'] = Read-Host -Prompt 'NestedInstallerType' | TrimString if ($_Installer['NestedInstallerType'] -Cin @($Patterns.ValidNestedInstallerTypes)) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Installer Type', "Value must exist in the enum - $(@($Patterns.ValidNestedInstallerTypes -join ', '))", 2) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } $_EffectiveType = Get-EffectiveInstallerType $_Installer $_NestedInstallerFiles = @() do { $_InstallerFile = [ordered] @{} $AnotherNestedInstaller = $false $_RelativePath = $null $_Alias = $null do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the relative path to the installer file' if (Test-String -not $_RelativePath -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $_RelativePath" } $_RelativePath = Read-Host -Prompt 'RelativeFilePath' | TrimString if (Test-String -not $_RelativePath -IsNull) { $_InstallerFile['RelativeFilePath'] = $_RelativePath } if (Test-String $_RelativePath -MinLength $Patterns.RelativeFilePathMinLength -MaxLength $Patterns.RelativeFilePathMaxLength) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.RelativeFilePathMinLength, $Patterns.RelativeFilePathMaxLength) } if ($_RelativePath -in @($_NestedInstallerFiles.RelativeFilePath)) { $script:_returnValue = [ReturnValue]::new(400, 'Path Collision', 'Relative file path must be unique', 2) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) if ($_EffectiveType -eq 'portable') { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the portable command alias' if (Test-String -not "$($_InstallerFile['PortableCommandAlias'])" -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $($_InstallerFile['PortableCommandAlias'])" } $_Alias = Read-Host -Prompt 'PortableCommandAlias' | TrimString if (Test-String -not $_Alias -IsNull) { $_InstallerFile['PortableCommandAlias'] = $_Alias } if (Test-String $_InstallerFile['PortableCommandAlias'] -MinLength $Patterns.PortableCommandAliasMinLength -MaxLength $Patterns.PortableCommandAliasMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.PortableCommandAliasMinLength, $Patterns.PortableCommandAliasMaxLength) } if ("$($_InstallerFile['PortableCommandAlias'])" -in @($_NestedInstallerFiles.PortableCommandAlias)) { $script:_returnValue = [ReturnValue]::new(400, 'Alias Collision', 'Aliases must be unique', 2) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Prompt to see if multiple entries are needed $_menu = @{ entries = @( '[Y] Yes' '*[N] No' ) Prompt = 'Do you want to create another portable installer entry?' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $AnotherNestedInstaller = $true } default { $AnotherNestedInstaller = $false } } if (!$AnotherNestedInstaller -and $script:Option -eq 'New') { # Prompt to see if the package depends on binaries being in the path $_menu = @{ entries = @( '[Y] Yes' '*[N] No' ) Prompt = 'Does this executable depend on DLLs or other files that are not available through Symlink?' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $_Installer['ArchiveBinariesDependOnPath'] = $true } # Not required to explicitly set as CLI defaults to false default { } } } } $_NestedInstallerFiles += $_InstallerFile } until (!$AnotherNestedInstaller) $_Installer['NestedInstallerFiles'] = $_NestedInstallerFiles } return $_Installer } Function Read-AppsAndFeaturesEntries { Param( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $_Installer ) $_AppsAndFeaturesEntries = @() # TODO: Support adding AppsAndFeaturesEntries if they don't exist if (!$_Installer.AppsAndFeaturesEntries) { return } # TODO: Support Multiple AppsAndFeaturesEntries once WinGet supports it # For now, only select and retain the first entry foreach ($_AppsAndFeaturesEntry in @($_Installer.AppsAndFeaturesEntries[0])) { $_AppsAndFeaturesEntries += Read-AppsAndFeaturesEntry $_AppsAndFeaturesEntry } return $_AppsAndFeaturesEntries } Function Read-AppsAndFeaturesEntry { Param( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $_AppsAndFeaturesEntry ) # TODO: Support adding new fields instead of only editing existing ones if ($_AppsAndFeaturesEntry.DisplayName) { $_AppsAndFeaturesEntry['DisplayName'] = Read-ARPDisplayName $_AppsAndFeaturesEntry.DisplayName } if ($_AppsAndFeaturesEntry.DisplayVersion) { $_AppsAndFeaturesEntry['DisplayVersion'] = Read-ARPDisplayVersion $_AppsAndFeaturesEntry.DisplayVersion } if ($_AppsAndFeaturesEntry.Publisher) { $_AppsAndFeaturesEntry['Publisher'] = Read-ARPPublisher $_AppsAndFeaturesEntry.Publisher } # TODO: Support ProductCode, UpgradeCode, and InstallerType return Restore-YamlKeyOrder $_AppsAndFeaturesEntry $AppsAndFeaturesEntryProperties -NoComments } Function Read-ARPDisplayName { Param( [Parameter(Mandatory = $true, Position = 0)] [string] $_DisplayName ) # Request DisplayName and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Recommended] Enter the application name as it appears in control panel' if (Test-String -not $_DisplayName -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $_DisplayName" } $NewValue = Read-Host -Prompt 'DisplayName' | TrimString if (Test-String -not $NewValue -IsNull) { $_DisplayName = $NewValue } if (Test-String $_DisplayName -MinLength $Patterns.ARP_DisplayNameMinLength -MaxLength $Patterns.ARP_DisplayNameMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.ARP_DisplayNameMinLength, $Patterns.ARP_DisplayNameMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) return $_DisplayName } Function Read-ARPPublisher { Param( [Parameter(Mandatory = $true, Position = 0)] [string] $_Publisher ) # Request Publisher Name and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Recommended] Enter the Publisher name as it appears in control panel' if (Test-String -not $_Publisher -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $_Publisher" } $NewValue = Read-Host -Prompt 'Publisher' | TrimString if (Test-String -not $NewValue -IsNull) { $_Publisher = $NewValue } if (Test-String $_Publisher -MinLength $Patterns.ARP_PublisherMinLength -MaxLength $Patterns.ARP_PublisherMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.ARP_PublisherMinLength, $Patterns.ARP_PublisherMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) return $_Publisher } Function Read-ARPDisplayVersion { Param( [Parameter(Mandatory = $true, Position = 0)] [string] $_DisplayVersion ) # Request DisplayVersion and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Recommended] Enter the application version as it appears in control panel' if (Test-String -not $_DisplayVersion -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $_DisplayVersion" } $NewValue = Read-Host -Prompt 'DisplayVersion' | TrimString if (Test-String -not $NewValue -IsNull) { $_DisplayVersion = $NewValue } if (Test-String $_DisplayVersion -MinLength $Patterns.ARP_DisplayVersionMinLength -MaxLength $Patterns.ARP_DisplayVersionMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.ARP_DisplayVersionMinLength, $Patterns.ARP_DisplayVersionMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) return $_DisplayVersion } # Prompts the user to enter installer values # Sets the $script:Installers value as an output # Returns void Function Read-InstallerEntry { $_Installer = [ordered] @{} # Request user enter Installer URL $_Installer['InstallerUrl'] = Request-InstallerUrl if ($_Installer.InstallerUrl -in ($script:Installers).InstallerUrl) { $_MatchingInstaller = $script:Installers | Where-Object { $_.InstallerUrl -eq $_Installer.InstallerUrl } | Select-Object -First 1 if ($_MatchingInstaller.InstallerSha256) { $_Installer['InstallerSha256'] = $_MatchingInstaller.InstallerSha256 } if ($_MatchingInstaller.InstallerType) { $_Installer['InstallerType'] = $_MatchingInstaller.InstallerType } if ($_MatchingInstaller.ProductCode) { $_Installer['ProductCode'] = $_MatchingInstaller.ProductCode } if ($_MatchingInstaller.PackageFamilyName) { $_Installer['PackageFamilyName'] = $_MatchingInstaller.PackageFamilyName } if ($_MatchingInstaller.SignatureSha256) { $_Installer['SignatureSha256'] = $_MatchingInstaller.SignatureSha256 } } # Get or request Installer Sha256 # Check the settings to see if we need to display this menu if ($_Installer.Keys -notcontains 'InstallerSha256') { $script:SaveOption = Get-UserSavePreference # If user did not select manual entry for Sha256, download file and calculate hash # Also attempt to detect installer type and architecture if ($script:SaveOption -ne '2') { Write-Host $start_time = Get-Date Write-Host $NewLine Write-Host 'Downloading URL. This will take a while...' -ForegroundColor Blue try { $script:dest = Get-InstallerFile -URI $_Installer['InstallerUrl'] -PackageIdentifier $PackageIdentifier -PackageVersion $PackageVersion } catch { # Here we also want to pass any exceptions through for potential debugging throw [System.Net.WebException]::new('The file could not be downloaded. Try running the script again', $_.Exception) } Write-Host "Time taken: $((Get-Date).Subtract($start_time).Seconds) second(s)" -ForegroundColor Green $_Installer['InstallerSha256'] = (Get-FileHash -Path $script:dest -Algorithm SHA256).Hash Resolve-InstallerType -Path $script:dest -OutVariable _ | Out-Null if ($_) { $_Installer['InstallerType'] = $_ | Select-Object -First 1 } Get-UriArchitecture -URI $_Installer['InstallerUrl'] -OutVariable _ | Out-Null if ($_) { $_Installer['Architecture'] = $_ | Select-Object -First 1 } Get-UriScope -URI $_Installer['InstallerUrl'] -OutVariable _ | Out-Null if ($_) { $_Installer['Scope'] = $_ | Select-Object -First 1 } if ([System.Environment]::OSVersion.Platform -match 'Win' -and ($script:dest).EndsWith('.msi')) { $ProductCode = ([string](Get-MSIProperty -Path $script:dest -Property 'ProductCode') | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } elseif ([System.Environment]::OSVersion.Platform -match 'Unix' -and (Get-Item $script:dest).Name.EndsWith('.msi')) { $ProductCode = ([string](file $script:dest) | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } if (Test-String -Not "$ProductCode" -IsNull) { $_Installer['ProductCode'] = "$ProductCode" } } # Manual Entry of Sha256 with validation else { Write-Host do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the installer SHA256 Hash' $_Installer['InstallerSha256'] = Read-Host -Prompt 'InstallerSha256' | TrimString $_Installer['InstallerSha256'] = $_Installer['InstallerSha256'].toUpper() if ($_Installer['InstallerSha256'] -match $Patterns.InstallerSha256) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::PatternError() } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } } # Manual Entry of Architecture with validation do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if (Test-String $_Installer['Architecture'] -IsNull) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the architecture. Options:' , @($Patterns.ValidArchitectures -join ', ') } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the architecture. Options:' , @($Patterns.ValidArchitectures -join ', ') Write-Host -ForegroundColor 'DarkGray' -Object "Old Variable: $($_Installer['Architecture'])" } Read-Host -Prompt 'Architecture' -OutVariable _ | Out-Null if (Test-String $_ -Not -IsNull) { $_Installer['Architecture'] = $_ | TrimString } if ($_Installer['Architecture'] -Cin @($Patterns.ValidArchitectures)) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Architecture', "Value must exist in the enum - $(@($Patterns.ValidArchitectures -join ', '))", 2) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Manual Entry of Installer Type with validation if ($_Installer['InstallerType'] -CNotIn @($Patterns.ValidInstallerTypes)) { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the InstallerType. Options:' , @($Patterns.ValidInstallerTypes -join ', ' ) $_Installer['InstallerType'] = Read-Host -Prompt 'InstallerType' | TrimString if ($_Installer['InstallerType'] -Cin @($Patterns.ValidInstallerTypes)) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Installer Type', "Value must exist in the enum - $(@($Patterns.ValidInstallerTypes -join ', '))", 2) } if ($_Installer['InstallerType'] -eq 'zip' -and [version]$ManifestVersion -lt [version]'1.4.0') { $script:_returnValue = [ReturnValue]::new(500, 'Zip Installer Not Supported', "Zip installers are only supported with ManifestVersion 1.4.0 or later. Current ManifestVersion: $ManifestVersion", 2) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # If the installer requires nested installer files, get them $_Installer = Read-NestedInstaller $_Installer $_Switches = [ordered] @{} # If Installer Type is `exe`, require the silent switches to be entered if ((Get-EffectiveInstallerType $_Installer) -ne 'portable') { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if ((Get-EffectiveInstallerType $_Installer) -ieq 'exe') { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the silent install switch. For example: /S, -verysilent, /qn, --silent, /exenoui' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the silent install switch. For example: /S, -verysilent, /qn, --silent, /exenoui' } Read-Host -Prompt 'Silent switch' -OutVariable _ | Out-Null if ($_) { $_Switches['Silent'] = $_ | TrimString } if (Test-String $_Switches['Silent'] -MaxLength $Patterns.SilentSwitchMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } elseif ((Get-EffectiveInstallerType $_Installer) -ne 'exe' -and (Test-String $_Switches['Silent'] -MaxLength $Patterns.SilentSwitchMaxLength -AllowNull)) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.SilentSwitchMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if ((Get-EffectiveInstallerType $_Installer) -ieq 'exe') { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the silent with progress install switch. For example: /S, -silent, /qb, /exebasicui' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the silent with progress install switch. For example: /S, -silent, /qb, /exebasicui' } Read-Host -Prompt 'Silent with progress switch' -OutVariable _ | Out-Null if ($_) { $_Switches['SilentWithProgress'] = $_ | TrimString } if (Test-String $_Switches['SilentWithProgress'] -MaxLength $Patterns.ProgressSwitchMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } elseif ((Get-EffectiveInstallerType $_Installer) -ne 'exe' -and (Test-String $_Switches['SilentWithProgress'] -MaxLength $Patterns.ProgressSwitchMaxLength -AllowNull)) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.ProgressSwitchMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Optional entry of `Custom` switches with validation for all installer types do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter any custom switches for the installer. For example: /norestart, -norestart' Read-Host -Prompt 'Custom Switch' -OutVariable _ | Out-Null if ($_) { $_Switches['Custom'] = $_ | TrimString } if (Test-String $_Switches['Custom'] -MaxLength $Patterns.CustomSwitchMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.CustomSwitchMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) if ($_Switches.Keys.Count -gt 0) { $_Installer['InstallerSwitches'] = $_Switches } # If the installer is `msix` or `appx`, prompt for or detect additional fields if ($_Installer['InstallerType'] -in @('msix'; 'appx')) { # Detect or prompt for Signature Sha256 if (Get-Command 'winget' -ErrorAction SilentlyContinue) { $SignatureSha256 = winget hash -m $script:dest | Select-String -Pattern 'SignatureSha256:' | ConvertFrom-String; if ($SignatureSha256.P2) { $SignatureSha256 = $SignatureSha256.P2.ToUpper() } } if ($SignatureSha256) { $_Installer['SignatureSha256'] = $SignatureSha256 } if (Test-String $_Installer['SignatureSha256'] -IsNull) { # Manual entry of Signature Sha256 with validation do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Recommended] Enter the installer SignatureSha256' Read-Host -Prompt 'SignatureSha256' -OutVariable _ | Out-Null if ($_) { $_Installer['SignatureSha256'] = $_ | TrimString } if (Test-String $_Installer['SignatureSha256'] -MatchPattern $Patterns.SignatureSha256 -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::PatternError() } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Prompt user to find package name automatically, unless package was not downloaded if ($script:SaveOption -eq '2' -or (!$(Test-Path $script:dest))) { $ChoicePfn = '1' } else { $_menu = @{ entries = @('*[F] Find Automatically'; '[M] Manually Enter PackageFamilyName') Prompt = 'Discover the package family name?' DefaultString = 'F' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'M' { $ChoicePfn = '1' } default { $ChoicePfn = '0' } } } # If user selected to find automatically - # Install package, get family name, uninstall package if ($ChoicePfn -eq '0') { $_Installer['PackageFamilyName'] = Get-PackageFamilyName $script:dest if (Test-String -not $_Installer['PackageFamilyName'] -MatchPattern $Patterns.FamilyName) { $script:_returnValue = [ReturnValue]::new(500, 'Could not find PackageFamilyName', 'Value should be entered manually', 1) } } # Validate Package Family Name if found automatically # Allow for manual entry if selected or if validation failed do { if (($ChoicePfn -ne '0') -or ($script:_returnValue.StatusCode -ne [ReturnValue]::Success().StatusCode)) { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Recommended] Enter the PackageFamilyName' Read-Host -Prompt 'PackageFamilyName' -OutVariable _ | Out-Null if ($_) { $_Installer['PackageFamilyName'] = $_ | TrimString } } if (Test-String $_Installer['PackageFamilyName'] -MaxLength $Patterns.FamilyNameMaxLength -MatchPattern $Patterns.FamilyName -AllowNull) { if (Test-String $_Installer['PackageFamilyName'] -IsNull) { $_Installer['PackageFamilyName'] = "$([char]0x2370)" } $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $_Installer['PackageFamilyName'] -MaxLength $Patterns.FamilyNameMaxLength) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.FamilyNameMaxLength) } elseif (Test-String -not $_Installer['PackageFamilyName'] -MatchPattern $Patterns.FamilyName) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Request installer locale with validation as optional do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the installer locale. For example: en-US, en-CA' Write-Host -ForegroundColor 'Blue' -Object 'https://docs.microsoft.com/openspecs/office_standards/ms-oe376/6c085406-a698-4e12-9d4d-c3b0ee3dbc4a' Read-Host -Prompt 'InstallerLocale' -OutVariable _ if ($_) { $_Installer['InstallerLocale'] = $_ | TrimString } # If user defined a default locale, add it if ((Test-String $_Installer['InstallerLocale'] -IsNull) -and (Test-String -not $ScriptSettings.DefaultInstallerLocale -IsNull)) { $_Installer['InstallerLocale'] = $ScriptSettings.DefaultInstallerLocale } if (Test-String $_Installer['InstallerLocale'] -MaxLength $Patterns.InstallerLocaleMaxLength -MatchPattern $Patterns.PackageLocale -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $_Installer['InstallerLocale'] -MaxLength $Patterns.InstallerLocaleMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(0, $Patterns.InstallerLocaleMaxLength) } elseif (Test-String -not $_Installer['InstallerLocale'] -MatchPattern $Patterns.PackageLocale) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request product code with validation if ((Get-EffectiveInstallerType $_Installer) -notmatch 'portable') { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application product code. Looks like {CF8E6E00-9C03-4440-81C0-21FACB921A6B}' Write-Host -ForegroundColor 'White' -Object "ProductCode found from installer: $($_Installer['ProductCode'])" Write-Host -ForegroundColor 'White' -Object 'Can be found with ' -NoNewline; Write-Host -ForegroundColor 'DarkYellow' 'get-wmiobject Win32_Product | Sort-Object Name | Format-Table IdentifyingNumber, Name -AutoSize' $NewProductCode = Read-Host -Prompt 'ProductCode' | TrimString if (Test-String $NewProductCode -Not -IsNull) { $_Installer['ProductCode'] = $NewProductCode } elseif (Test-String $_Installer['ProductCode'] -Not -IsNull) { $_Installer['ProductCode'] = "$($_Installer['ProductCode'])" } if (Test-String $_Installer['ProductCode'] -MinLength $Patterns.ProductCodeMinLength -MaxLength $Patterns.ProductCodeMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.ProductCodeMinLength, $Patterns.ProductCodeMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Manual Entry of Scope if (Test-String $_Installer['Scope'] -IsNull) { $_menu = @{ entries = @('[M] Machine'; '[U] User'; '*[N] No idea') Prompt = '[Optional] Enter the Installer Scope' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'M' { $_Installer['Scope'] = 'machine' } 'U' { $_Installer['Scope'] = 'user' } default { } } } # Request upgrade behavior $_menu = @{ entries = @('*[I] Install'; '[U] Uninstall Previous') Prompt = '[Optional] Enter the Upgrade Behavior' DefaultString = 'I' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'U' { $_Installer['UpgradeBehavior'] = 'uninstallPrevious' } default { $_Installer['UpgradeBehavior'] = 'install' } } Write-Host } # Request release date $script:ReleaseDatePrompted = $true do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application release date. Example: 2021-11-17' Read-Host -Prompt 'ReleaseDate' -OutVariable ReleaseDate | Out-Null try { Get-Date([datetime]$($ReleaseDate | TrimString)) -f 'yyyy-MM-dd' -OutVariable _ValidDate | Out-Null if ($_ValidDate) { $_Installer['ReleaseDate'] = $_ValidDate | TrimString } $script:_returnValue = [ReturnValue]::Success() } catch { if (Test-String $ReleaseDate -IsNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Date', 'Input could not be resolved to a date', 2) } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) $AppsAndFeaturesEntries = Read-AppsAndFeaturesEntries $_Installer if ($AppsAndFeaturesEntries) { $_Installer['AppsAndFeaturesEntries'] = @($AppsAndFeaturesEntries) } if ($script:SaveOption -eq '1' -and (Test-Path -Path $script:dest)) { $script:CleanupPaths += $script:dest } # If the installers array is empty, create it if (!$script:Installers) { $script:Installers = @() } # Add the completed installer to the installers array $_Installer = Restore-YamlKeyOrder $_Installer $InstallerEntryProperties -NoComments $script:Installers += $_Installer # Prompt the user for additional intaller entries $_menu = @{ entries = @( '[Y] Yes' '*[N] No' ) Prompt = 'Do you want to create another installer?' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $AnotherInstaller = '0' } 'N' { $AnotherInstaller = '1' } default { $AnotherInstaller = '1' } } # If there are additional entries, run this function again to fetch the values and add them to the installers array if ($AnotherInstaller -eq '0') { Write-Host; Read-InstallerEntry } } # Prompts user for Installer Values using the `Quick Update` Method # Sets the $script:Installers value as an output # Returns void Function Read-QuickInstallerEntry { # We know old manifests exist if we got here without error # Fetch the old installers based on the manifest type if ($script:OldInstallerManifest) { $_OldInstallers = $script:OldInstallerManifest['Installers'] } else { $_OldInstallers = $script:OldVersionManifest['Installers'] } $_iteration = 0 $_NewInstallers = @() foreach ($_OldInstaller in $_OldInstallers) { # Create the new installer as an exact copy of the old installer entry # This is to ensure all previously entered and un-modified parameters are retained $_iteration += 1 $_NewInstaller = $_OldInstaller $_NewInstaller.Remove('InstallerSha256'); # Show the user which installer entry they should be entering information for Write-Host -ForegroundColor 'Green' "Installer Entry #$_iteration`:`n" if ($_OldInstaller.InstallerLocale) { Write-Host -ForegroundColor 'Yellow' "`tInstallerLocale: $($_OldInstaller.InstallerLocale)" } if ($_OldInstaller.Architecture) { Write-Host -ForegroundColor 'Yellow' "`tArchitecture: $($_OldInstaller.Architecture)" } if ($_OldInstaller.InstallerType) { Write-Host -ForegroundColor 'Yellow' "`tInstallerType: $($_OldInstaller.InstallerType)" } if ($_OldInstaller.NestedInstallerType ) { Write-Host -ForegroundColor 'Yellow' "`tNestedInstallerType: $($_OldInstaller.NestedInstallerType)" } if ($_OldInstaller.Scope) { Write-Host -ForegroundColor 'Yellow' "`tScope: $($_OldInstaller.Scope)" } Write-Host # Request user enter the new Installer URL $_NewInstaller['InstallerUrl'] = Request-InstallerUrl if ($_NewInstaller.InstallerUrl -in ($_NewInstallers).InstallerUrl) { $_MatchingInstaller = $_NewInstallers | Where-Object { $_.InstallerUrl -eq $_NewInstaller.InstallerUrl } | Select-Object -First 1 if ($_MatchingInstaller.InstallerSha256) { $_NewInstaller['InstallerSha256'] = $_MatchingInstaller.InstallerSha256 } if ($_MatchingInstaller.InstallerType) { $_NewInstaller['InstallerType'] = $_MatchingInstaller.InstallerType } if ($_MatchingInstaller.ProductCode) { $_NewInstaller['ProductCode'] = $_MatchingInstaller.ProductCode } elseif ( ($_NewInstaller.Keys -contains 'ProductCode') -and ($script:dest -notmatch '.exe$')) { $_NewInstaller.Remove('ProductCode') } if ($_MatchingInstaller.PackageFamilyName) { $_NewInstaller['PackageFamilyName'] = $_MatchingInstaller.PackageFamilyName } elseif ($_NewInstaller.Keys -contains 'PackageFamilyName') { $_NewInstaller.Remove('PackageFamilyName') } if ($_MatchingInstaller.SignatureSha256) { $_NewInstaller['SignatureSha256'] = $_MatchingInstaller.SignatureSha256 } elseif ($_NewInstaller.Keys -contains 'SignatureSha256') { $_NewInstaller.Remove('SignatureSha256') } } if ($_NewInstaller.Keys -notcontains 'InstallerSha256') { try { Write-Host -ForegroundColor 'Green' 'Downloading Installer. . .' $script:dest = Get-InstallerFile -URI $_NewInstaller['InstallerUrl'] -PackageIdentifier $PackageIdentifier -PackageVersion $PackageVersion } catch { # Here we also want to pass any exceptions through for potential debugging throw [System.Net.WebException]::new('The file could not be downloaded. Try running the script again', $_.Exception) } # Check that MSI's aren't actually WIX, and EXE's aren't NSIS, INNO or BURN Write-Host -ForegroundColor 'Green' "Installer Downloaded!`nProcessing installer data. . . " if ($_NewInstaller['InstallerType'] -in @('msi'; 'exe')) { $DetectedType = Resolve-InstallerType $script:dest if ($DetectedType -in @('msi'; 'wix'; 'nullsoft'; 'inno'; 'burn')) { $_NewInstaller['InstallerType'] = $DetectedType } } # Get the Sha256 $_NewInstaller['InstallerSha256'] = (Get-FileHash -Path $script:dest -Algorithm SHA256).Hash # Update the product code, if a new one exists # If a new product code doesn't exist, and the installer isn't an `.exe` file, remove the product code if it exists $MSIProductCode = $null if ([System.Environment]::OSVersion.Platform -match 'Win' -and ($script:dest).EndsWith('.msi')) { $MSIProductCode = ([string](Get-MSIProperty -Path $script:dest -Property 'ProductCode') | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } elseif ([System.Environment]::OSVersion.Platform -match 'Unix' -and (Get-Item $script:dest).Name.EndsWith('.msi')) { $MSIProductCode = ([string](file $script:dest) | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } if (Test-String -not $MSIProductCode -IsNull) { $_NewInstaller['ProductCode'] = $MSIProductCode } elseif ( ($_NewInstaller.Keys -contains 'ProductCode') -and ((Get-EffectiveInstallerType $_NewInstaller) -in @('appx'; 'msi'; 'msix'; 'wix'; 'burn'))) { $_NewInstaller.Remove('ProductCode') } # If the installer is msix or appx, try getting the new SignatureSha256 # If the new SignatureSha256 can't be found, remove it if it exists $NewSignatureSha256 = $null if ($_NewInstaller.InstallerType -in @('msix', 'appx')) { if (Get-Command 'winget' -ErrorAction SilentlyContinue) { $NewSignatureSha256 = winget hash -m $script:dest | Select-String -Pattern 'SignatureSha256:' | ConvertFrom-String; if ($NewSignatureSha256.P2) { $NewSignatureSha256 = $NewSignatureSha256.P2.ToUpper() } } } if (Test-String -not $NewSignatureSha256 -IsNull) { $_NewInstaller['SignatureSha256'] = $NewSignatureSha256 } elseif ($_NewInstaller.Keys -contains 'SignatureSha256') { $_NewInstaller.Remove('SignatureSha256') } # If the installer is msix or appx, try getting the new package family name # If the new package family name can't be found, remove it if it exists if ($script:dest -match '\.(msix|appx)(bundle){0,1}$') { $PackageFamilyName = Get-PackageFamilyName $script:dest if (Test-String $PackageFamilyName -MatchPattern $Patterns.FamilyName) { $_NewInstaller['PackageFamilyName'] = $PackageFamilyName } elseif ($_NewInstaller.Keys -contains 'PackageFamilyName') { $_NewInstaller.Remove('PackageFamilyName') } } # Remove the downloaded files Remove-Item -Path $script:dest Write-Host -ForegroundColor 'Green' "Installer updated!`n" } # Force a re-check of the Nested Installer Paths in case they changed between versions $_NewInstaller = Read-NestedInstaller $_NewInstaller # Force a re-check of the ARP entries in case they changed between versions $AppsAndFeaturesEntries = Read-AppsAndFeaturesEntries $_NewInstaller if ($AppsAndFeaturesEntries) { $_NewInstaller['AppsAndFeaturesEntries'] = @($AppsAndFeaturesEntries) } #Add the updated installer to the new installers array $_NewInstaller = Restore-YamlKeyOrder $_NewInstaller $InstallerEntryProperties -NoComments $_NewInstallers += $_NewInstaller } $script:Installers = $_NewInstallers } # Requests the user enter an optional value with a prompt # If the value already exists, also print the existing value # Returns the new value if entered, Returns the existing value if no new value was entered Function Read-InstallerMetadataValue { Param ( [Parameter(Mandatory = $true, Position = 0)] [AllowNull()] [PSCustomObject] $Variable, [Parameter(Mandatory = $true, Position = 1)] [string] $Key, [Parameter(Mandatory = $true, Position = 2)] [string] $Prompt ) Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object $Prompt if (Test-String -not $Variable -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $Variable" } $NewValue = Read-Host -Prompt $Key | TrimString if (Test-String -not $NewValue -IsNull) { return $NewValue } else { return $Variable } } # Sorts keys within an object based on a reference ordered dictionary # If a key does not exist, it sets the value to a special character to be removed / commented later # Returns the result as a new object Function Restore-YamlKeyOrder { Param ( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $InputObject, [Parameter(Mandatory = $true, Position = 1)] [PSCustomObject] $SortOrder, [switch] $NoComments ) $_ExcludedKeys = @( 'InstallerSwitches' 'Capabilities' 'RestrictedCapabilities' 'InstallerSuccessCodes' 'ProductCode' 'UpgradeCode' 'PackageFamilyName' 'InstallerLocale' 'InstallerType' 'NestedInstallerType' 'NestedInstallerFiles' 'Scope' 'UpgradeBehavior' 'Dependencies' 'InstallationMetadata' 'Platform' 'Icons' 'Agreements' ) $_Temp = [ordered] @{} $SortOrder.GetEnumerator() | ForEach-Object { if ($InputObject.Contains($_)) { $_Temp.Add($_, $InputObject[$_]) } else { if (!$NoComments -and $_ -notin $_ExcludedKeys) { $_Temp.Add($_, "$([char]0x2370)") } } } return $_Temp } # Requests the user to input optional values for the Installer Manifest file Function Read-InstallerMetadata { Write-Host # Request File Extensions and validate do { if (!$FileExtensions) { $FileExtensions = '' } else { $FileExtensions = $FileExtensions | ToLower | UniqueItems } $script:FileExtensions = Read-InstallerMetadataValue -Variable $FileExtensions -Key 'FileExtensions' -Prompt "[Optional] Enter any File Extensions the application could support. For example: html, htm, url (Max $($Patterns.MaxItemsFileExtensions))" | ToLower | UniqueItems if (($script:FileExtensions -split ',').Count -le $Patterns.MaxItemsFileExtensions -and $($script:FileExtensions.Split(',').Trim() | Where-Object { Test-String -Not $_ -MaxLength $Patterns.FileExtensionMaxLength -MatchPattern $Patterns.FileExtension -AllowNull }).Count -eq 0) { $script:_returnValue = [ReturnValue]::Success() } else { if (($script:FileExtensions -split ',').Count -gt $Patterns.MaxItemsFileExtensions ) { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.MaxItemsFileExtensions) } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Entries', "Some entries do not match the requirements defined in the manifest schema - $($script:FileExtensions.Split(',').Trim() | Where-Object { Test-String -Not $_ -MaxLength $Patterns.FileExtensionMaxLength -MatchPattern $Patterns.FileExtension })", 2) } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Protocols and validate do { if (!$Protocols) { $Protocols = '' } else { $Protocols = $Protocols | ToLower | UniqueItems } $script:Protocols = Read-InstallerMetadataValue -Variable $Protocols -Key 'Protocols' -Prompt "[Optional] Enter any Protocols the application provides a handler for. For example: http, https (Max $($Patterns.MaxItemsProtocols))" | ToLower | UniqueItems if (($script:Protocols -split ',').Count -le $Patterns.MaxItemsProtocols) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.MaxItemsProtocols) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Commands and validate do { if (!$Commands) { $Commands = '' } else { $Commands = $Commands | UniqueItems } $script:Commands = Read-InstallerMetadataValue -Variable $Commands -Key 'Commands' -Prompt "[Optional] Enter any Commands or aliases to run the application. For example: msedge (Max $($Patterns.MaxItemsCommands))" | UniqueItems if (($script:Commands -split ',').Count -le $Patterns.MaxItemsCommands) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.MaxItemsCommands) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Installer Success Codes and validate do { if (!$InstallerSuccessCodes) { $InstallerSuccessCodes = '' } $script:InstallerSuccessCodes = Read-InstallerMetadataValue -Variable $InstallerSuccessCodes -Key 'InstallerSuccessCodes' -Prompt "[Optional] List of additional non-zero installer success exit codes other than known default values by winget (Max $($Patterns.MaxItemsSuccessCodes))" | UniqueItems if (($script:InstallerSuccessCodes -split ',').Count -le $Patterns.MaxItemsSuccessCodes) { $script:_returnValue = [ReturnValue]::Success() try { #Ensure all values are integers $script:InstallerSuccessCodes.Split(',').Trim() | ForEach-Object { [long]$_ } $script:_returnValue = [ReturnValue]::Success() } catch { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Data Type', 'The value entered does not match the type requirements defined in the manifest schema', 2) } } else { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.MaxItemsSuccessCodes) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Install Modes and validate do { if ($script:InstallModes) { $script:InstallModes = $script:InstallModes | UniqueItems } $script:InstallModes = Read-InstallerMetadataValue -Variable $script:InstallModes -Key 'InstallModes' -Prompt "[Optional] List of supported installer modes. Options: $($Patterns.ValidInstallModes -join ', ')" if ($script:InstallModes) { $script:InstallModes = $script:InstallModes | UniqueItems } if ( (Test-String $script:InstallModes -IsNull) -or (($script:InstallModes -split ',').Count -le $Patterns.MaxItemsInstallModes -and $($script:InstallModes.Split(',').Trim() | Where-Object { $_ -CNotIn $Patterns.ValidInstallModes }).Count -eq 0)) { $script:_returnValue = [ReturnValue]::Success() } else { if (($script:InstallModes -split ',').Count -gt $Patterns.MaxItemsInstallModes ) { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.MaxItemsInstallModes) } else { $script:_returnValue = [ReturnValue]::new(400, 'Invalid Entries', "Some entries do not match the requirements defined in the manifest schema - $($script:InstallModes.Split(',').Trim() | Where-Object { $_ -CNotIn $Patterns.ValidInstallModes })", 2) } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Requests the user to input values for the Locale Manifest file Function Read-LocaleMetadata { # Request Package Locale and Validate if (Test-String -not $script:PackageLocale -MaxLength $Patterns.PackageLocaleMaxLength -MatchPattern $Patterns.PackageLocale -NotNull) { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the Package Locale. For example: en-US, en-CA' Write-Host -ForegroundColor 'Blue' 'Reference Link: https://docs.microsoft.com/openspecs/office_standards/ms-oe376/6c085406-a698-4e12-9d4d-c3b0ee3dbc4a' $script:PackageLocale = Read-Host -Prompt 'PackageLocale' | TrimString if (Test-String $script:PackageLocale -MaxLength $Patterns.PackageLocaleMaxLength -MatchPattern $Patterns.PackageLocale -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String $script:PackageLocale -not -MaxLength $Patterns.PackageLocaleMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.PackageLocaleMaxLength) } elseif (Test-String $script:PackageLocale -not -MatchPattern $Patterns.PackageLocale ) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Request Publisher Name and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if (Test-String $script:Publisher -IsNull) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the full publisher name. For example: Microsoft Corporation' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the full publisher name. For example: Microsoft Corporation' Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Publisher" } $NewPublisher = Read-Host -Prompt 'Publisher' | TrimString if (Test-String $NewPublisher -NotNull) { $script:Publisher = $NewPublisher } if (Test-String $script:Publisher -MaxLength $Patterns.PublisherMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.PublisherMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Application Name and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if (Test-String $script:PackageName -IsNull) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the full application name. For example: Microsoft Teams' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the full application name. For example: Microsoft Teams' Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:PackageName" } $NewPackageName = Read-Host -Prompt 'PackageName' | TrimString if (Test-String -not $NewPackageName -IsNull) { $script:PackageName = $NewPackageName } if (Test-String $script:PackageName -MaxLength $Patterns.PackageNameMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.PackageNameMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # If the option is `NewLocale` then these moniker should already exist # If the option is not `NewLocale`, Request Moniker and Validate if ($Option -ne 'NewLocale') { do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Moniker (friendly name/alias). For example: vscode' if (Test-String -not $script:Moniker -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Moniker" } $NewMoniker = Read-Host -Prompt 'Moniker' | ToLower | TrimString | NoWhitespace if (Test-String -not $NewMoniker -IsNull) { $script:Moniker = $NewMoniker } if (Test-String $script:Moniker -MaxLength $Patterns.MonikerMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.MonikerMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } #Request Publisher URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Publisher Url.' if (Test-String -not $script:PublisherUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:PublisherUrl" } $NewPublisherUrl = Read-Host -Prompt 'Publisher Url' | TrimString if (Test-String -not $NewPublisherUrl -IsNull) { $script:PublisherUrl = $NewPublisherUrl } if (Test-String $script:PublisherUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:PublisherUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:PublisherUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Publisher Support URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Publisher Support Url.' if (Test-String -not $script:PublisherSupportUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:PublisherSupportUrl" } $NewPublisherSupportUrl = Read-Host -Prompt 'Publisher Support Url' | TrimString if (Test-String -not $NewPublisherSupportUrl -IsNull) { $script:PublisherSupportUrl = $NewPublisherSupportUrl } if (Test-String $script:PublisherSupportUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:PublisherSupportUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:PublisherSupportUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Publisher Privacy URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Publisher Privacy Url.' if (Test-String -not $script:PrivacyUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:PrivacyUrl" } $NewPrivacyUrl = Read-Host -Prompt 'Publisher Privacy Url' | TrimString if (Test-String -not $NewPrivacyUrl -IsNull) { $script:PrivacyUrl = $NewPrivacyUrl } if (Test-String $script:PrivacyUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:PrivacyUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:PrivacyUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Author and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application Author.' if (Test-String -not $script:Author -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Author" } $NewAuthor = Read-Host -Prompt 'Author' | TrimString if (Test-String -not $NewAuthor -IsNull) { $script:Author = $NewAuthor } if (Test-String $script:Author -MinLength $Patterns.AuthorMinLength -MaxLength $Patterns.AuthorMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.AuthorMinLength, $Patterns.AuthorMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Package URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Url to the homepage of the application.' if (Test-String -not $script:PackageUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:PackageUrl" } $NewPackageUrl = Read-Host -Prompt 'Homepage' | TrimString if (Test-String -not $NewPackageUrl -IsNull) { $script:PackageUrl = $NewPackageUrl } if (Test-String $script:PackageUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:PackageUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:PackageUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request License and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if (Test-String $script:License -IsNull) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the application License. For example: MIT, GPL, Freeware, Proprietary' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application License. For example: MIT, GPL, Freeware, Proprietary' Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:License" } $NewLicense = Read-Host -Prompt 'License' | TrimString if (Test-String -not $NewLicense -IsNull) { $script:License = $NewLicense } if (Test-String $script:License -MinLength $Patterns.LicenseMinLength -MaxLength $Patterns.LicenseMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } elseif (Test-String $script:License -IsNull) { $script:_returnValue = [ReturnValue]::new(400, 'Required Field', 'The value entered cannot be null or empty', 2) } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.LicenseMinLength, $Patterns.LicenseMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request License URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application License URL.' if (Test-String -not $script:LicenseUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:LicenseUrl" } $NewLicenseUrl = Read-Host -Prompt 'License URL' | TrimString if (Test-String -not $NewLicenseUrl -IsNull) { $script:LicenseUrl = $NewLicenseUrl } if (Test-String $script:LicenseUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:LicenseUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:LicenseUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Copyright and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application Copyright.' Write-Host -ForegroundColor 'Blue' 'Example: Copyright (c) Microsoft Corporation' if (Test-String -not $script:Copyright -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Copyright" } $NewCopyright = Read-Host -Prompt 'Copyright' | TrimString if (Test-String -not $NewCopyright -IsNull) { $script:Copyright = $NewCopyright } if (Test-String $script:Copyright -MinLength $Patterns.CopyrightMinLength -MaxLength $Patterns.CopyrightMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.CopyrightMinLength, $Patterns.CopyrightMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Copyright URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the application Copyright Url.' if (Test-String -not $script:CopyrightUrl -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:CopyrightUrl" } $NewCopyrightUrl = Read-Host -Prompt 'CopyrightUrl' | TrimString if (Test-String -not $NewCopyrightUrl -IsNull) { $script:CopyrightUrl = $NewCopyrightUrl } if (Test-String $script:CopyrightUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:CopyrightUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:CopyrightUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Tags and Validate do { $script:Tags = [string]$script:Tags Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter any tags that would be useful to discover this tool.' Write-Host -ForegroundColor 'Blue' -Object 'Example: zip, c++, photos, OBS (Max', ($Patterns.TagsMaxItems), 'items)' if (Test-String -not $script:Tags -IsNull) { $script:Tags = $script:Tags | ToLower | UniqueItems Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Tags" } $NewTags = Read-Host -Prompt 'Tags' | TrimString | ToLower | UniqueItems if (Test-String -not $NewTags -IsNull) { $script:Tags = $NewTags } if (($script:Tags -split ',').Count -le $Patterns.TagsMaxItems) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::MaxItemsError($Patterns.TagsMaxItems) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Short Description and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() if (Test-String $script:ShortDescription -IsNull) { Write-Host -ForegroundColor 'Green' -Object '[Required] Enter a short description of the application.' } else { Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter a short description of the application.' Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:ShortDescription" } $NewShortDescription = Read-Host -Prompt 'Short Description' | TrimString if (Test-String -not $NewShortDescription -IsNull) { $script:ShortDescription = $NewShortDescription } if (Test-String $script:ShortDescription -MaxLength $Patterns.ShortDescriptionMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.ShortDescriptionMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request Long Description and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter a long description of the application.' if (Test-String -not $script:Description -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $script:Description" } $NewDescription = Read-Host -Prompt 'Description' | TrimString if (Test-String -not $NewDescription -IsNull) { $script:Description = $NewDescription } if (Test-String $script:Description -MinLength $Patterns.DescriptionMinLength -MaxLength $Patterns.DescriptionMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.DescriptionMinLength, $Patterns.DescriptionMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request ReleaseNotes and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter release notes for this version of the package.' $script:ReleaseNotes = Read-Host -Prompt 'ReleaseNotes' | TrimString if (Test-String $script:ReleaseNotes -MinLength $Patterns.ReleaseNotesMinLength -MaxLength $Patterns.ReleaseNotesMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError($Patterns.ReleaseNotesMinLength, $Patterns.ReleaseNotesMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Request ReleaseNotes URL and Validate do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the release notes URL for this version of the package.' $script:ReleaseNotesUrl = Read-Host -Prompt 'ReleaseNotesUrl' | TrimString if (Test-String $script:ReleaseNotesUrl -MaxLength $Patterns.GenericUrlMaxLength -MatchPattern $Patterns.GenericUrl -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $script:ReleaseNotesUrl -MaxLength $Patterns.GenericUrlMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.GenericUrlMaxLength) } elseif (Test-String -not $script:ReleaseNotesUrl -MatchPattern $Patterns.GenericUrl) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) } # Requests the user to answer the prompts found in the winget-pkgs pull request template # Uses this template and responses to create a PR Function Read-PRBody { $PrBodyContent = Get-Content $args[0] ForEach ($_line in $PrBodyContent) { # | Where-Object { $_ -like '-*[ ]*' })) if ($_line -like '-*[ ]*' ) { $_showMenu = $true switch -Wildcard ( $_line ) { '*CLA*' { if ($ScriptSettings.SignedCLA -eq 'true') { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) $_showMenu = $false } else { $_menu = @{ Prompt = 'Have you signed the Contributor License Agreement (CLA)?' Entries = @('[Y] Yes'; '*[N] No') HelpText = 'Reference Link: https://cla.opensource.microsoft.com/microsoft/winget-pkgs' HelpTextColor = '' DefaultString = 'N' } } } '*open `[pull requests`]*' { $_menu = @{ Prompt = "Have you checked that there aren't other open pull requests for the same manifest update/change?" Entries = @('[Y] Yes'; '*[N] No') HelpText = 'Reference Link: https://github.com/microsoft/winget-pkgs/pulls' HelpTextColor = '' DefaultString = 'N' } } '*winget validate*' { if ($? -and $(Get-Command 'winget' -ErrorAction SilentlyContinue)) { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) $_showMenu = $false } elseif ($script:Option -ne 'RemoveManifest') { $_menu = @{ Prompt = "Have you validated your manifest locally with 'winget validate --manifest '?" Entries = @('[Y] Yes'; '*[N] No') HelpText = 'Automatic manifest validation failed. Check your manifest and try again' HelpTextColor = 'Red' DefaultString = 'N' } } else { $_showMenu = $false } } '*tested your manifest*' { if ($script:SandboxTest -eq '0') { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) $_showMenu = $false } elseif ($script:Option -ne 'RemoveManifest') { $_menu = @{ Prompt = "Have you tested your manifest locally with 'winget install --manifest '?" Entries = @('[Y] Yes'; '*[N] No') HelpText = 'You did not test your Manifest in Windows Sandbox previously.' HelpTextColor = 'Red' DefaultString = 'N' } } else { $_showMenu = $false } } '*schema*' { if ($script:Option -ne 'RemoveManifest') { $_Match = ($_line | Select-String -Pattern 'https://+.+(?=\))').Matches.Value $_menu = @{ Prompt = $_line.TrimStart('- [ ]') -replace '\[|\]|\(.+\)', '' Entries = @('[Y] Yes'; '*[N] No') HelpText = "Reference Link: $_Match" HelpTextColor = '' DefaultString = 'N' } } else { $_showMenu = $false } } '*only modifies one*' { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) $_showMenu = $false } '*linked issue*' { # Linked issues is handled as a separate prompt below so that the issue numbers can be gathered $_showMenu = $false } Default { $_menu = @{ Prompt = $_line.TrimStart('- [ ]') Entries = @('[Y] Yes'; '*[N] No') HelpText = '' HelpTextColor = '' DefaultString = 'N' } } } if ($_showMenu) { switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] -HelpTextColor $_menu['HelpTextColor']) { 'Y' { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) } default { } } } } } # Request user to enter if there were any issues resolved by the PR $_menu = @{ entries = @('[Y] Yes'; '*[N] No') Prompt = 'Does this pull request resolve any issues?' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $_line = ($PrBodyContent | Select-String 'linked issue').Line if ($_line) { $PrBodyContent = $PrBodyContent.Replace($_line, $_line.Replace('[ ]', '[x]')) } # If there were issues resolved by the PR, request user to enter them Write-Host Write-Host "Enter issue number. For example`: 21983, 43509" $ResolvedIssues = Read-Host -Prompt 'Resolved Issues' | UniqueItems $PrBodyContent += @('') # Validate each of the issues entered by checking the URL to ensure it returns a 200 status code Foreach ($i in ($ResolvedIssues.Split(',').Trim())) { if ($i.Contains('#')) { $_UrlParameters = $i.Split('#') switch ($_UrlParameters.Count) { 2 { if ([string]::IsNullOrWhiteSpace($_urlParameters[0])) { $_checkedURL = "https://github.com/microsoft/winget-pkgs/issues/$($_urlParameters[1])" } else { $_checkedURL = "https://github.com/$($_urlParameters[0])/issues/$($_urlParameters[1])" } } default { Write-Host -ForegroundColor 'Red' "Invalid Issue: $i" continue } } $_responseCode = Test-Url $_checkedURL if ($_responseCode -ne 200) { Write-Host -ForegroundColor 'Red' "Invalid Issue: $i" continue } $PrBodyContent += @("Resolves $i") } else { $_checkedURL = "https://github.com/microsoft/winget-pkgs/issues/$i" $_responseCode = Test-Url $_checkedURL if ($_responseCode -ne 200) { Write-Host -ForegroundColor 'Red' "Invalid Issue: $i" continue } $PrBodyContent += @("* Resolves #$i") } } } default { Write-Host } } # If we are removing a manifest, we need to include the reason if ($CommitType -eq 'Remove') { $PrBodyContent = @("## $($script:RemovalReason)"; '') + $PrBodyContent } # Write the PR using a temporary file Set-Content -Path PrBodyFile -Value $PrBodyContent | Out-Null gh pr create --body-file PrBodyFile -f Remove-Item PrBodyFile } # Takes a comma separated list of values, converts it to an array object, and adds the result to a specified object-key Function Add-YamlListParameter { Param ( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $Object, [Parameter(Mandatory = $true, Position = 1)] [string] $Parameter, [Parameter(Mandatory = $true, Position = 2)] $Values ) $_Values = @() Foreach ($Value in $Values.Split(',').Trim()) { $_Values += $Value } $Object[$Parameter] = $_Values } # Takes a single value and adds it to a specified object-key Function Add-YamlParameter { Param ( [Parameter(Mandatory = $true, Position = 0)] [PSCustomObject] $Object, [Parameter(Mandatory = $true, Position = 1)] [string] $Parameter, [Parameter(Mandatory = $true, Position = 2)] [string] $Value ) $Object[$Parameter] = $Value } # Fetch the value of a manifest value regardless of which manifest file it exists in Function Get-MultiManifestParameter { Param( [Parameter(Mandatory = $true, Position = 1)] [string] $Parameter ) $_vals = $($script:OldInstallerManifest[$Parameter] + $script:OldLocaleManifest[$Parameter] + $script:OldVersionManifest[$Parameter] | Where-Object { $_ }) return ($_vals -join ', ') } Function Get-DebugString { $debug = ' $debug=' $debug += $(switch ($script:Option) { 'New' { 'NV' } 'QuickUpdateVersion' { 'QU' } 'EditMetadata' { 'MD' } 'NewLocale' { 'NL' } 'Auto' { 'AU' } Default { 'XX' } }) $debug += $( switch ($script:SaveOption) { '0' { 'S0.' } '1' { 'S1.' } '2' { 'S2.' } Default { 'SU.' } } ) $debug += $(switch (([System.Environment]::NewLine).Length) { 1 { 'LF.' } 2 { 'CRLF.' } Default { 'XX.' } }) $debug += $PSVersionTable.PSVersion -Replace '\.', '-' $debug += '.' $debug += [System.Environment]::OSVersion.Platform return $debug } Function Write-ManifestContent { Param ( [Parameter(Mandatory = $true, Position = 0)] [string] $FilePath, [Parameter(Mandatory = $true, Position = 1)] [PSCustomObject] $YamlContent, [Parameter(Mandatory = $true, Position = 2)] [string] $Schema ) [System.IO.File]::WriteAllLines($FilePath, @( $ScriptHeader + $(Get-DebugString); "# yaml-language-server: `$schema=$Schema"; ''; # This regex looks for lines with the special character ⍰ and comments them out $(ConvertTo-Yaml $YamlContent).TrimEnd() -replace "(.*)\s+$([char]0x2370)", "# `$1" ), $Utf8NoBomEncoding) Write-Host "Yaml file created: $FilePath" } # Take all the entered values and write the version manifest file Function Write-VersionManifest { # Create new empty manifest [PSCustomObject]$VersionManifest = [ordered]@{} # Write these values into the manifest $_Singletons = [ordered]@{ 'PackageIdentifier' = $PackageIdentifier 'PackageVersion' = $PackageVersion 'DefaultLocale' = if ($PackageLocale) { $PackageLocale } else { 'en-US' } 'ManifestType' = 'version' 'ManifestVersion' = $ManifestVersion } foreach ($_Item in $_Singletons.GetEnumerator()) { If ($_Item.Value) { Add-YamlParameter -Object $VersionManifest -Parameter $_Item.Name -Value $_Item.Value } } $VersionManifest = Restore-YamlKeyOrder $VersionManifest $VersionProperties # Create the folder for the file if it doesn't exist New-Item -ItemType 'Directory' -Force -Path $AppFolder | Out-Null $script:VersionManifestPath = Join-Path $AppFolder -ChildPath "$PackageIdentifier.yaml" # Write the manifest to the file Write-ManifestContent -FilePath $VersionManifestPath -YamlContent $VersionManifest -Schema $SchemaUrls.version } # Take all the entered values and write the installer manifest file Function Write-InstallerManifest { # If the old manifests exist, copy it so it can be updated in place, otherwise, create a new empty manifest if ($script:OldManifestType -eq 'MultiManifest') { $InstallerManifest = $script:OldInstallerManifest } if (!$InstallerManifest) { [PSCustomObject]$InstallerManifest = [ordered]@{} } #Add the properties to the manifest Add-YamlParameter -Object $InstallerManifest -Parameter 'PackageIdentifier' -Value $PackageIdentifier Add-YamlParameter -Object $InstallerManifest -Parameter 'PackageVersion' -Value $PackageVersion If ($MinimumOSVersion) { $InstallerManifest['MinimumOSVersion'] = $MinimumOSVersion } Else { If ($InstallerManifest['MinimumOSVersion']) { $_InstallerManifest.Remove('MinimumOSVersion') } } $_ListSections = [ordered]@{ 'FileExtensions' = $FileExtensions 'Protocols' = $Protocols 'Commands' = $Commands 'InstallerSuccessCodes' = $InstallerSuccessCodes 'InstallModes' = $InstallModes } foreach ($Section in $_ListSections.GetEnumerator()) { If ($Section.Value) { Add-YamlListParameter -Object $InstallerManifest -Parameter $Section.Name -Values $Section.Value } } if ($Option -ne 'EditMetadata') { $InstallerManifest['Installers'] = $script:Installers } elseif ($script:OldInstallerManifest) { $InstallerManifest['Installers'] = $script:OldInstallerManifest['Installers'] } else { $InstallerManifest['Installers'] = $script:OldVersionManifest['Installers'] } foreach ($_Installer in $InstallerManifest.Installers) { if ($_Installer['ReleaseDate'] -and !$script:ReleaseDatePrompted -and !$Preserve) { $_Installer.Remove('ReleaseDate') } elseif ($Preserve) { try { Get-Date([datetime]$($_Installer['ReleaseDate'])) -f 'yyyy-MM-dd' -OutVariable _ValidDate | Out-Null if ($_ValidDate) { $_Installer['ReleaseDate'] = $_ValidDate | TrimString } } catch { # Release date isn't valid $_Installer.Remove('ReleaseDate') } } } Add-YamlParameter -Object $InstallerManifest -Parameter 'ManifestType' -Value 'installer' Add-YamlParameter -Object $InstallerManifest -Parameter 'ManifestVersion' -Value $ManifestVersion If ($InstallerManifest['Dependencies']) { $InstallerManifest['Dependencies'] = Restore-YamlKeyOrder $InstallerManifest['Dependencies'] $InstallerDependencyProperties -NoComments } # Move Installer Level Keys to Manifest Level $_KeysToMove = $InstallerEntryProperties | Where-Object { $_ -in $InstallerProperties -and $_ -notin @('ProductCode', 'NestedInstallerFiles', 'NestedInstallerType') } foreach ($_Key in $_KeysToMove) { if ($_Key -in $InstallerManifest.Installers[0].Keys) { # Handle the switches specially if ($_Key -eq 'InstallerSwitches') { # Go into each of the subkeys to see if they are the same foreach ($_InstallerSwitchKey in $InstallerManifest.Installers[0].$_Key.Keys) { $_AllAreSame = $true $_FirstInstallerSwitchKeyValue = ConvertTo-Json($InstallerManifest.Installers[0].$_Key.$_InstallerSwitchKey) foreach ($_Installer in $InstallerManifest.Installers) { $_CurrentInstallerSwitchKeyValue = ConvertTo-Json($_Installer.$_Key.$_InstallerSwitchKey) if (Test-String $_CurrentInstallerSwitchKeyValue -IsNull) { $_AllAreSame = $false } else { $_AllAreSame = $_AllAreSame -and (@(Compare-Object $_CurrentInstallerSwitchKeyValue $_FirstInstallerSwitchKeyValue).Length -eq 0) } } if ($_AllAreSame) { if ($_Key -notin $InstallerManifest.Keys) { $InstallerManifest[$_Key] = @{} } $InstallerManifest.$_Key[$_InstallerSwitchKey] = $InstallerManifest.Installers[0].$_Key.$_InstallerSwitchKey } } # Remove them from the individual installer switches if we moved them to the manifest level if ($_Key -in $InstallerManifest.Keys) { foreach ($_InstallerSwitchKey in $InstallerManifest.$_Key.Keys) { foreach ($_Installer in $InstallerManifest.Installers) { if ($_Installer.Keys -contains $_Key) { if ($_Installer.$_Key.Keys -contains $_InstallerSwitchKey) { $_Installer.$_Key.Remove($_InstallerSwitchKey) } if (@($_Installer.$_Key.Keys).Count -eq 0) { $_Installer.Remove($_Key) } } } } } } else { # Check if all installers are the same $_AllAreSame = $true $_FirstInstallerKeyValue = ConvertTo-Json($InstallerManifest.Installers[0].$_Key) foreach ($_Installer in $InstallerManifest.Installers) { $_CurrentInstallerKeyValue = ConvertTo-Json($_Installer.$_Key) if (Test-String $_CurrentInstallerKeyValue -IsNull) { $_AllAreSame = $false } else { $_AllAreSame = $_AllAreSame -and (@(Compare-Object $_CurrentInstallerKeyValue $_FirstInstallerKeyValue).Length -eq 0) } } # If all installers are the same move the key to the manifest level if ($_AllAreSame) { $InstallerManifest[$_Key] = $InstallerManifest.Installers[0].$_Key foreach ($_Installer in $InstallerManifest.Installers) { $_Installer.Remove($_Key) } } } } } if ($InstallerManifest.Keys -contains 'InstallerSwitches') { $InstallerManifest['InstallerSwitches'] = Restore-YamlKeyOrder $InstallerManifest.InstallerSwitches $InstallerSwitchProperties -NoComments } foreach ($_Installer in $InstallerManifest.Installers) { if ($_Installer.Keys -contains 'InstallerSwitches') { $_Installer['InstallerSwitches'] = Restore-YamlKeyOrder $_Installer.InstallerSwitches $InstallerSwitchProperties -NoComments } } # Clean up the existing files just in case if ($InstallerManifest['Commands']) { $InstallerManifest['Commands'] = @($InstallerManifest['Commands'] | NoWhitespace | UniqueItems | Sort-Object) } if ($InstallerManifest['Protocols']) { $InstallerManifest['Protocols'] = @($InstallerManifest['Protocols'] | ToLower | NoWhitespace | UniqueItems | Sort-Object) } if ($InstallerManifest['FileExtensions']) { $InstallerManifest['FileExtensions'] = @($InstallerManifest['FileExtensions'] | ToLower | NoWhitespace | UniqueItems | Sort-Object) } $InstallerManifest = Restore-YamlKeyOrder $InstallerManifest $InstallerProperties -NoComments # Create the folder for the file if it doesn't exist New-Item -ItemType 'Directory' -Force -Path $AppFolder | Out-Null $script:InstallerManifestPath = Join-Path $AppFolder -ChildPath "$PackageIdentifier.installer.yaml" # Write the manifest to the file Write-ManifestContent -FilePath $InstallerManifestPath -YamlContent $InstallerManifest -Schema $SchemaUrls.installer } # Take all the entered values and write the locale manifest file Function Write-LocaleManifest { # If the old manifests exist, copy it so it can be updated in place, otherwise, create a new empty manifest if ($script:OldManifestType -eq 'MultiManifest') { $LocaleManifest = $script:OldLocaleManifest } if (!$LocaleManifest) { [PSCustomObject]$LocaleManifest = [ordered]@{} } # Add the properties to the manifest $_Singletons = [ordered]@{ 'PackageIdentifier' = $PackageIdentifier 'PackageVersion' = $PackageVersion 'PackageLocale' = $PackageLocale 'Publisher' = $Publisher 'PublisherUrl' = $PublisherUrl 'PublisherSupportUrl' = $PublisherSupportUrl 'PrivacyUrl' = $PrivacyUrl 'Author' = $Author 'PackageName' = $PackageName 'PackageUrl' = $PackageUrl 'License' = $License 'LicenseUrl' = $LicenseUrl 'Copyright' = $Copyright 'CopyrightUrl' = $CopyrightUrl 'ShortDescription' = $ShortDescription 'Description' = $Description 'ReleaseNotes' = $ReleaseNotes 'ReleaseNotesUrl' = $ReleaseNotesUrl } foreach ($_Item in $_Singletons.GetEnumerator()) { If ($_Item.Value) { Add-YamlParameter -Object $LocaleManifest -Parameter $_Item.Name -Value $_Item.Value } } If ($Tags) { Add-YamlListParameter -Object $LocaleManifest -Parameter 'Tags' -Values $Tags } If (!$LocaleManifest.ManifestType) { $LocaleManifest['ManifestType'] = 'defaultLocale' } If ($Moniker -and $($LocaleManifest.ManifestType -eq 'defaultLocale')) { Add-YamlParameter -Object $LocaleManifest -Parameter 'Moniker' -Value $Moniker } Add-YamlParameter -Object $LocaleManifest -Parameter 'ManifestVersion' -Value $ManifestVersion # Clean up the existing files just in case if ($LocaleManifest['Tags']) { $LocaleManifest['Tags'] = @($LocaleManifest['Tags'] | ToLower | NoWhitespace | UniqueItems | Sort-Object) } if ($LocaleManifest['Moniker']) { $LocaleManifest['Moniker'] = $LocaleManifest['Moniker'] | ToLower | NoWhitespace } # Clean up the volatile fields if ($LocaleManifest['ReleaseNotes'] -and (Test-String $script:ReleaseNotes -IsNull) -and !$Preserve) { $LocaleManifest.Remove('ReleaseNotes') } if ($LocaleManifest['ReleaseNotesUrl'] -and (Test-String $script:ReleaseNotesUrl -IsNull) -and !$Preserve) { $LocaleManifest.Remove('ReleaseNotesUrl') } $LocaleManifest = Restore-YamlKeyOrder $LocaleManifest $LocaleProperties # Set the appropriate langage server depending on if it is a default locale file or generic locale file if ($LocaleManifest.ManifestType -eq 'defaultLocale') { $yamlServer = $SchemaUrls.defaultLocale } else { $yamlServer = $SchemaUrls.locale } # Create the folder for the file if it doesn't exist New-Item -ItemType 'Directory' -Force -Path $AppFolder | Out-Null $script:LocaleManifestPath = Join-Path $AppFolder -ChildPath "$PackageIdentifier.locale.$PackageLocale.yaml" # Write the manifest to the file Write-ManifestContent -FilePath $LocaleManifestPath -YamlContent $LocaleManifest -Schema $yamlServer # Copy over all locale files from previous version that aren't the same if ($OldManifests) { ForEach ($DifLocale in $OldManifests) { if ($DifLocale.Name -notin @("$PackageIdentifier.yaml", "$PackageIdentifier.installer.yaml", "$PackageIdentifier.locale.$PackageLocale.yaml")) { if (!(Test-Path $AppFolder)) { New-Item -ItemType 'Directory' -Force -Path $AppFolder | Out-Null } $script:OldLocaleManifest = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $DifLocale.FullName -Encoding UTF8) -join "`n") -Ordered $script:OldLocaleManifest['PackageVersion'] = $PackageVersion if ($script:OldLocaleManifest.Keys -contains 'Moniker') { $script:OldLocaleManifest.Remove('Moniker') } $script:OldLocaleManifest['ManifestVersion'] = $ManifestVersion # Clean up the existing files just in case if ($script:OldLocaleManifest['Tags']) { $script:OldLocaleManifest['Tags'] = @($script:OldLocaleManifest['Tags'] | ToLower | NoWhitespace | UniqueItems | Sort-Object) } # Clean up the volatile fields if ($OldLocaleManifest['ReleaseNotes'] -and (Test-String $script:ReleaseNotes -IsNull) -and !$Preserve) { $OldLocaleManifest.Remove('ReleaseNotes') } if ($OldLocaleManifest['ReleaseNotesUrl'] -and (Test-String $script:ReleaseNotesUrl -IsNull) -and !$Preserve) { $OldLocaleManifest.Remove('ReleaseNotesUrl') } $script:OldLocaleManifest = Restore-YamlKeyOrder $script:OldLocaleManifest $LocaleProperties Write-ManifestContent -FilePath $(Join-Path $AppFolder -ChildPath $DifLocale.Name) -YamlContent $OldLocaleManifest -Schema $SchemaUrls.locale } } } } function Remove-ManifestVersion { [CmdletBinding(SupportsShouldProcess)] Param( [Parameter(Mandatory = $true, Position = 1)] [string] $PathToVersion ) # Remove the manifest, and then any parent folders so long as the parent folders are empty do { Remove-Item -Path $PathToVersion -Recurse -Force $PathToVersion = Split-Path $PathToVersion } while (@(Get-ChildItem $PathToVersion).Count -eq 0) return $PathToVersion } ## START OF MAIN SCRIPT ## # Set the root folder where new manifests should be created if (Test-Path -Path "$PSScriptRoot\..\manifests") { $ManifestsFolder = (Resolve-Path "$PSScriptRoot\..\manifests").Path } else { $ManifestsFolder = (Resolve-Path '.\').Path } # Initialize the return value to be a success $script:_returnValue = [ReturnValue]::new(200) $script:UsingAdvancedOption = ($ScriptSettings.EnableDeveloperOptions -eq 'true') -and ($AutoUpgrade) if (!$script:UsingAdvancedOption) { # Request the user to choose an operation mode Clear-Host if ($Mode -in 1..6) { $UserChoice = $Mode } else { Write-Host @" ${vtForegroundYellow} Select Mode: ${vtForegroundCyan}[${vtForegroundWhite}1${vtForegroundCyan}] New Manifest or Package Version ${vtForegroundCyan}[${vtForegroundWhite}2${vtForegroundCyan}] Quick Update Package Version ${vtForegroundCyan}[${vtForegroundWhite}3${vtForegroundCyan}] Update Package Metadata ${vtForegroundGreen}(Note: Must be used only when previous version's metadata is complete.) ${vtForegroundCyan}[${vtForegroundWhite}4${vtForegroundCyan}] New Locale ${vtForegroundCyan}[${vtForegroundWhite}5${vtForegroundCyan}] Remove a manifest ${vtForegroundCyan}[${vtForegroundWhite}6${vtForegroundCyan}] Move package to a new identifier ${vtForegroundCyan}[${vtForegroundWhite}Q${vtForegroundCyan}] ${vtForegroundRed}Any key to quit ${vtForegroundDefault} "@ Write-Host "Selection: " -NoNewLine # Listen for keypress and set operation mode based on keypress $Keys = @{ [ConsoleKey]::D1 = '1'; [ConsoleKey]::D2 = '2'; [ConsoleKey]::D3 = '3'; [ConsoleKey]::D4 = '4'; [ConsoleKey]::D5 = '5'; [ConsoleKey]::D6 = '6'; [ConsoleKey]::NumPad1 = '1'; [ConsoleKey]::NumPad2 = '2'; [ConsoleKey]::NumPad3 = '3'; [ConsoleKey]::NumPad4 = '4'; [ConsoleKey]::NumPad5 = '5'; [ConsoleKey]::NumPad6 = '6'; } do { $keyInfo = [Console]::ReadKey($false) } until ($keyInfo.Key) $UserChoice = $Keys[$keyInfo.Key] } switch ($UserChoice) { '1' { $script:Option = 'New' } '2' { $script:Option = 'QuickUpdateVersion' } '3' { $script:Option = 'EditMetadata' } '4' { $script:Option = 'NewLocale' } '5' { $script:Option = 'RemoveManifest' } '6' { $script:Option = 'MovePackageIdentifier' } default { Write-Host [Threading.Thread]::CurrentThread.CurrentUICulture = $callingUICulture exit } } } else { if ($AutoUpgrade) { $script:Option = 'Auto' } } # Confirm the user understands the implications of using the quick update mode if (($script:Option -eq 'QuickUpdateVersion') -and ($ScriptSettings.SuppressQuickUpdateWarning -ne 'true')) { $_menu = @{ entries = @('[Y] Continue with Quick Update'; '[N] Use Full Update Experience'; '*[Q] Exit Script') Prompt = 'Quick Updates only allow for changes to the existing Installer URLs, Sha256 Values, and Product Codes. Are you sure you want to continue?' HelpText = 'This mode should be used with caution. If you are not 100% certain this is correct, please use Option 1 to go through the full update experience' HelpTextColor = 'Red' DefaultString = 'Q' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] -HelpTextColor $_menu['HelpTextColor']) { 'Y' { Write-Host -ForegroundColor DarkYellow -Object "`n`nContinuing with Quick Update" } 'N' { $script:Option = 'New'; Write-Host -ForegroundColor DarkYellow -Object "`n`nSwitched to Full Update Experience" } default { Invoke-CleanExit } } } Write-Host # Confirm the user understands the implications of moving package if (($script:Option -eq 'MovePackageIdentifier')) { $_menu = @{ entries = @('[Y] Continue moving package'; '*[Q] Exit Script') Prompt = 'Packages should only be moved between identifiers when necessary. Are you sure you want to continue?' HelpText = 'This mode should be used with caution. If you are not 100% certain what you are doing, please open an issue at GitHub' HelpTextColor = 'Red' DefaultString = 'Q' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] -HelpTextColor $_menu['HelpTextColor']) { 'Y' { # To move a package doesn't require a package version like the other functions of YamlCreate. Therefore, to avoid requesting the information twice # the entirety of the Move packages script happens here. This will then exit the script directly. Write-Host; Write-Host # Update the ref for upstream master and switch to it to ensure the latest manifest information git fetch upstream master --quiet git switch -d upstream/master -q # Request the current identifier and validate that it exists Write-Host -ForegroundColor 'Green' -Object 'What is the current package identifier?' -NoNewline do { $OldPackageIdentifier = Read-PackageIdentifier -PackageIdentifier $null # Set the folder for the specific package $FromAppFolder = Join-Path $ManifestsFolder -ChildPath $OldPackageIdentifier.ToLower().Chars(0) | Join-Path -ChildPath $OldPackageIdentifier.Replace('.', $([IO.Path]::DirectorySeparatorChar)) if (!(Test-Path -Path "$FromAppFolder")) { Write-Host -ForegroundColor 'Red' -Object "No manifests found for $OldPackageIdentifier" } else { $manifestsExist = $true Write-Host } } while (!$manifestsExist) # Request the new identifier Write-Host -ForegroundColor 'Green' -Object 'What is the new package identifier?' -NoNewline $NewPackageIdentifier = Read-PackageIdentifier -PackageIdentifier $null $ToAppFolder = Join-Path $ManifestsFolder -ChildPath $NewPackageIdentifier.ToLower().Chars(0) | Join-Path -ChildPath $NewPackageIdentifier.Replace('.', [IO.Path]::DirectorySeparatorChar) Write-Host # Request the new moniker, in case the moniker needs to be updated do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Yellow' -Object '[Optional] Enter the Moniker (friendly name/alias). For example: vscode' if (Test-String -not $NewMoniker -IsNull) { Write-Host -ForegroundColor 'DarkGray' "Old Variable: $NewMoniker" } $NewMoniker = Read-Host -Prompt 'Moniker' | ToLower | TrimString | NoWhitespace if (Test-String $NewMoniker -MaxLength $Patterns.MonikerMaxLength -AllowNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.MonikerMaxLength) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Get a list of the versions to move $VersionsToMove = @(Get-ChildItem -Path $FromAppFolder | Where-Object { @(Get-ChildItem -Directory -Path $_.FullName).Count -eq 0 }).Name # Create an array for logging all the branches that were created $BranchesCreated = @() foreach ($Version in $VersionsToMove) { Write-Host Write-Host -ForegroundColor Yellow -Object "Moving version $Version [$(1+$BranchesCreated.Count/2)/$($VersionsToMove.Count)]" # Copy the manifests to the new directory $SourceFolder = Join-Path -Path $FromAppFolder -ChildPath $Version $DestinationFolder = Join-Path -Path $ToAppFolder -ChildPath $Version Copy-Item -Path $SourceFolder -Destination $DestinationFolder -Recurse -Force # Rename the files Get-ChildItem -Path $DestinationFolder -Filter "*$OldPackageIdentifier*" -Recurse | ForEach-Object { Rename-Item -Path $_.FullName -NewName $($_.Name -replace [regex]::Escape($OldPackageIdentifier), "$NewPackageIdentifier") } # Update PackageIdentifier in all files Get-ChildItem -Path $DestinationFolder -Filter "*$NewPackageIdentifier*" -Recurse | ForEach-Object { [System.IO.File]::WriteAllLines($_.FullName, $((Get-Content -Path $_.FullName -Raw).TrimEnd() -replace [regex]::Escape($OldPackageIdentifier), "$NewPackageIdentifier"), $Utf8NoBomEncoding) } # Update Moniker in all files if (Test-String $NewMoniker -Not -IsNull) { Get-ChildItem -Path $DestinationFolder -Filter "*$NewPackageIdentifier*" -Recurse | ForEach-Object { [System.IO.File]::WriteAllLines($_.FullName, $((Get-Content -Path $_.FullName -Raw).TrimEnd() -replace 'Moniker:.*', "Moniker: $NewMoniker"), $Utf8NoBomEncoding) } } # Create and push to a new branch git switch -d upstream/master -q git add $DestinationFolder git commit -m "Move $OldPackageIdentifier $Version to $NewPackageIdentifier $Version" --quiet $BranchName = "Move-$OldPackageIdentifier-v$Version-$RunHash" git switch -c "$BranchName" --quiet git push --set-upstream origin "$BranchName" --quiet $BranchesCreated += $BranchName if ($ScriptSettings.AutoSubmitPRs -eq 'Always') { gh pr create -f } # Switch back to the master branch git switch -d upstream/master -q # Remove the manifest for the old version # Create and push to a new branch git add $(Remove-ManifestVersion $SourceFolder) git commit -m "Remove $OldPackageIdentifier $Version to $NewPackageIdentifier $Version" --quiet $BranchName = "Remove-$OldPackageIdentifier-v$Version-$RunHash" git switch -c "$BranchName" --quiet git push --set-upstream origin "$BranchName" --quiet $BranchesCreated += $BranchName if ($ScriptSettings.AutoSubmitPRs -eq 'Always') { gh pr create -f } } } default { Out-Null # Intentionally do nothing here } } if ($ScriptSettings.AutoSubmitPRs -notin @('Always', 'Never') -and $BranchesCreated.Count -gt 0) { $_menu = @{ entries = @('[Y] Yes'; '*[N] No') Prompt = "Do you want to submit all $($BranchesCreated.Count) PRs now?" HelpText = "If you choose 'No', the pull requests will need to be manually created" DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText']) { 'Y' { foreach ($Branch in $BranchesCreated) { git switch $Branch --quiet gh pr create -f } } default { Out-Null } } } Invoke-CleanExit } # Request Package Identifier and Validate $script:PackageIdentifier = Read-PackageIdentifier $script:PackageIdentifier # Request Package Version and Validate do { if ((Test-String $PackageVersion -IsNull) -or ($script:_returnValue.StatusCode -ne [ReturnValue]::Success().StatusCode)) { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the version. for example: 1.33.7' $script:PackageVersion = Read-Host -Prompt 'Version' | TrimString } if (Test-String $PackageVersion -MaxLength $Patterns.VersionMaxLength -MatchPattern $Patterns.PackageVersion -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { if (Test-String -not $PackageVersion -MaxLength $Patterns.VersionMaxLength -NotNull) { $script:_returnValue = [ReturnValue]::LengthError(1, $Patterns.VersionMaxLength) } elseif (Test-String -not $PackageVersion -MatchPattern $Patterns.PackageVersion) { $script:_returnValue = [ReturnValue]::PatternError() } else { $script:_returnValue = [ReturnValue]::GenericError() } } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) # Check the api for open PR's # This is unauthenticated because the call-rate per minute is assumed to be low if ($ScriptSettings.ContinueWithExistingPRs -ne 'always' -and $script:Option -ne 'RemoveManifest' -and !$SkipPRCheck) { $PRApiResponse = @(Invoke-WebRequest "https://api.github.com/search/issues?q=repo%3Amicrosoft%2Fwinget-pkgs%20is%3Apr%20$($PackageIdentifier -replace '\.', '%2F'))%2F$PackageVersion%20in%3Apath&per_page=1" -UseBasicParsing -ErrorAction SilentlyContinue | ConvertFrom-Json)[0] # If there was a PR found, get the URL and title if ($PRApiResponse.total_count -gt 0) { $_PRUrl = $PRApiResponse.items.html_url $_PRTitle = $PRApiResponse.items.title if ($ScriptSettings.ContinueWithExistingPRs -eq 'never') { Write-Host -ForegroundColor Red "Existing PR Found - $_PRUrl" Invoke-CleanExit } $_menu = @{ entries = @('[Y] Yes'; '*[N] No') Prompt = 'There may already be a PR for this change. Would you like to continue anyways?' DefaultString = 'N' HelpText = "$_PRTitle - $_PRUrl" HelpTextColor = 'Blue' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] -HelpTextColor $_menu['HelpTextColor'] ) { 'Y' { Write-Host } default { Invoke-CleanExit } } } } # Set the folder for the specific package and version $script:AppFolder = Join-Path $ManifestsFolder -ChildPath $PackageIdentifier.ToLower().Chars(0) | Join-Path -ChildPath $PackageIdentifierFolder | Join-Path -ChildPath $PackageVersion # If the user selected `NewLocale` or `EditMetadata` the version *MUST* already exist in the folder structure if ($script:Option -in @('NewLocale'; 'EditMetadata'; 'RemoveManifest')) { # Try getting the old manifests from the specified folder if (Test-Path -Path "$AppFolder\..\$PackageVersion") { $script:OldManifests = Get-ChildItem -Path "$AppFolder\..\$PackageVersion" $LastVersion = $PackageVersion } # If the old manifests could not be found, request a new version while (-not ($OldManifests.Name -like "$PackageIdentifier*.yaml")) { Write-Host Write-Host -ForegroundColor 'Red' -Object 'Could not find required manifests, input a version containing required manifests or "exit" to cancel' $PromptVersion = Read-Host -Prompt 'Version' | TrimString if ($PromptVersion -eq 'exit') { Invoke-CleanExit } if (Test-Path -Path "$AppFolder\..\$PromptVersion") { $script:OldManifests = Get-ChildItem -Path "$AppFolder\..\$PromptVersion" } # If a new version is entered, we need to be sure to update the folder for writing manifests $LastVersion = $PromptVersion $script:AppFolder = Join-Path (Split-Path $AppFolder) -ChildPath $LastVersion $script:PackageVersion = $LastVersion } } # If the user selected `QuickUpdateVersion`, the old manifests must exist # If the user selected `New`, the old manifest type is specified as none if (-not (Test-Path -Path "$AppFolder\..")) { if ($script:Option -in @('QuickUpdateVersion', 'Auto')) { Write-Host -ForegroundColor Red 'This option requires manifest of previous version of the package. If you want to create a new package, please select Option 1.' Invoke-CleanExit } $script:OldManifestType = 'None' } # Try getting the last version of the package and the old manifests to be updated if (!$LastVersion) { try { $script:LastVersion = Split-Path (Split-Path (Get-ChildItem -Path "$AppFolder\..\" -Recurse -Depth 1 -File -Filter '*.yaml' -ErrorAction SilentlyContinue).FullName ) -Leaf | Sort-Object $ToNatural | Select-Object -Last 1 $script:ExistingVersions = Split-Path (Split-Path (Get-ChildItem -Path "$AppFolder\..\" -Recurse -Depth 1 -File -Filter '*.yaml' -ErrorAction SilentlyContinue).FullName ) -Leaf | Sort-Object $ToNatural | Select-Object -Unique if ($script:Option -eq 'Auto' -and $PackageVersion -in $script:ExistingVersions) { $LastVersion = $PackageVersion } Write-Host -ForegroundColor 'DarkYellow' -Object "Found Existing Version: $LastVersion" $script:OldManifests = Get-ChildItem -Path "$AppFolder\..\$LastVersion" } catch { # Take no action here, we just want to catch the exceptions as a precaution Out-Null } } # If the old manifests exist, find the default locale if ($OldManifests.Name -match "$([Regex]::Escape($PackageIdentifier))\.locale\..*\.yaml") { $_LocaleManifests = $OldManifests | Where-Object { $_.Name -match "$([Regex]::Escape($PackageIdentifier))\.locale\..*\.yaml" } foreach ($_Manifest in $_LocaleManifests) { $_ManifestContent = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $($_Manifest.FullName) -Encoding UTF8) -join "`n") -Ordered if ($_ManifestContent.ManifestType -eq 'defaultLocale') { $PackageLocale = $_ManifestContent.PackageLocale } } } # If the old manifests exist, read their information into variables # Also ensure additional requirements are met for creating or updating files if ($OldManifests.Name -eq "$PackageIdentifier.installer.yaml" -and $OldManifests.Name -eq "$PackageIdentifier.locale.$PackageLocale.yaml" -and $OldManifests.Name -eq "$PackageIdentifier.yaml") { $script:OldManifestType = 'MultiManifest' $script:OldInstallerManifest = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $(Resolve-Path "$AppFolder\..\$LastVersion\$PackageIdentifier.installer.yaml") -Encoding UTF8) -join "`n") -Ordered # Move Manifest Level Keys to installer Level $_KeysToMove = $InstallerEntryProperties | Where-Object { $_ -in $InstallerProperties } foreach ($_Key in $_KeysToMove) { if ($_Key -in $script:OldInstallerManifest.Keys) { # Handle Installer switches separately if ($_Key -eq 'InstallerSwitches') { $_SwitchKeysToMove = $script:OldInstallerManifest.$_Key.Keys foreach ($_SwitchKey in $_SwitchKeysToMove) { # If the InstallerSwitches key doesn't exist, we need to create it, otherwise, preserve switches that were already there foreach ($_Installer in $script:OldInstallerManifest['Installers']) { if ('InstallerSwitches' -notin $_Installer.Keys) { $_Installer['InstallerSwitches'] = @{} } $_Installer.InstallerSwitches["$_SwitchKey"] = $script:OldInstallerManifest.$_Key.$_SwitchKey } } $script:OldInstallerManifest.Remove($_Key) continue } else { foreach ($_Installer in $script:OldInstallerManifest['Installers']) { if ($_Key -eq 'InstallModes') { $script:InstallModes = [string]$script:OldInstallerManifest.$_Key } if ($_Key -notin $_Installer.Keys) { $_Installer[$_Key] = $script:OldInstallerManifest.$_Key } } } New-Variable -Name $_Key -Value $($script:OldInstallerManifest.$_Key -join ', ') -Scope Script -Force $script:OldInstallerManifest.Remove($_Key) } } $script:OldLocaleManifest = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $(Resolve-Path "$AppFolder\..\$LastVersion\$PackageIdentifier.locale.$PackageLocale.yaml") -Encoding UTF8) -join "`n") -Ordered $script:OldVersionManifest = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $(Resolve-Path "$AppFolder\..\$LastVersion\$PackageIdentifier.yaml") -Encoding UTF8) -join "`n") -Ordered } elseif ($OldManifests.Name -eq "$PackageIdentifier.yaml") { if ($script:Option -eq 'NewLocale') { throw [ManifestException]::new('MultiManifest Required') } $script:OldManifestType = 'MultiManifest' $script:OldSingletonManifest = ConvertFrom-Yaml -Yaml ($(Get-Content -Path $(Resolve-Path "$AppFolder\..\$LastVersion\$PackageIdentifier.yaml") -Encoding UTF8) -join "`n") -Ordered $PackageLocale = $script:OldSingletonManifest.PackageLocale # Create new empty manifests $script:OldInstallerManifest = [ordered]@{} $script:OldLocaleManifest = [ordered]@{} $script:OldVersionManifest = [ordered]@{} # Parse version keys to version manifest foreach ($_Key in $($OldSingletonManifest.Keys | Where-Object { $_ -in $VersionProperties })) { $script:OldVersionManifest[$_Key] = $script:OldSingletonManifest.$_Key } $script:OldVersionManifest['ManifestType'] = 'version' #Parse locale keys to locale manifest foreach ($_Key in $($OldSingletonManifest.Keys | Where-Object { $_ -in $LocaleProperties })) { $script:OldLocaleManifest[$_Key] = $script:OldSingletonManifest.$_Key } $script:OldLocaleManifest['ManifestType'] = 'defaultLocale' #Parse installer keys to installer manifest foreach ($_Key in $($OldSingletonManifest.Keys | Where-Object { $_ -in $InstallerProperties })) { $script:OldInstallerManifest[$_Key] = $script:OldSingletonManifest.$_Key } $script:OldInstallerManifest['ManifestType'] = 'installer' # Move Manifest Level Keys to installer Level $_KeysToMove = $InstallerEntryProperties | Where-Object { $_ -in $InstallerProperties } foreach ($_Key in $_KeysToMove) { if ($_Key -in $script:OldInstallerManifest.Keys) { # Handle Installer switches separately if ($_Key -eq 'InstallerSwitches') { $_SwitchKeysToMove = $script:OldInstallerManifest.$_Key.Keys foreach ($_SwitchKey in $_SwitchKeysToMove) { # If the InstallerSwitches key doesn't exist, we need to create it, otherwise, preserve switches that were already there foreach ($_Installer in $script:OldInstallerManifest['Installers']) { if ('InstallerSwitches' -notin $_Installer.Keys) { $_Installer['InstallerSwitches'] = @{} } $_Installer.InstallerSwitches["$_SwitchKey"] = $script:OldInstallerManifest.$_Key.$_SwitchKey } } $script:OldInstallerManifest.Remove($_Key) continue } else { foreach ($_Installer in $script:OldInstallerManifest['Installers']) { if ($_Key -eq 'InstallModes') { $script:InstallModes = [string]$script:OldInstallerManifest.$_Key } if ($_Key -notin $_Installer.Keys) { $_Installer[$_Key] = $script:OldInstallerManifest.$_Key } } } New-Variable -Name $_Key -Value $($script:OldInstallerManifest.$_Key -join ', ') -Scope Script -Force $script:OldInstallerManifest.Remove($_Key) } } } else { if ($script:Option -ne 'New') { throw [ManifestException]::new("Version $LastVersion does not contain the required manifests") } $script:OldManifestType = 'None' } # If the old manifests exist, read the manifest keys into their specific variables if ($OldManifests -and $Option -ne 'NewLocale') { $_Parameters = @( 'Publisher'; 'PublisherUrl'; 'PublisherSupportUrl'; 'PrivacyUrl' 'Author'; 'PackageName'; 'PackageUrl'; 'Moniker' 'License'; 'LicenseUrl' 'Copyright'; 'CopyrightUrl' 'ShortDescription'; 'Description' 'Channel' 'Platform'; 'MinimumOSVersion' 'InstallerType'; 'NestedInstallerType' 'Scope' 'UpgradeBehavior' 'PackageFamilyName'; 'ProductCode' 'Tags'; 'FileExtensions' 'Protocols'; 'Commands' 'InstallerSuccessCodes' 'Capabilities'; 'RestrictedCapabilities' ) Foreach ($param in $_Parameters) { $_ReadValue = $(if ($script:OldManifestType -eq 'MultiManifest') { (Get-MultiManifestParameter $param) } else { $script:OldVersionManifest[$param] }) if (Test-String -Not $_ReadValue -IsNull) { New-Variable -Name $param -Value $_ReadValue -Scope Script -Force } } } # If the old manifests exist, make sure to use the same casing as the existing package identifier if ($OldManifests) { $script:PackageIdentifier = $OldManifests.Where({ $_.Name -like "$PackageIdentifier.yaml" }).BaseName } # Run the data entry and creation of manifests appropriate to the option the user selected Switch ($script:Option) { 'QuickUpdateVersion' { Read-QuickInstallerEntry Write-LocaleManifest Write-InstallerManifest Write-VersionManifest } 'New' { Read-InstallerEntry Read-InstallerMetadata Read-LocaleMetadata Write-InstallerManifest Write-VersionManifest Write-LocaleManifest } 'EditMetadata' { Read-InstallerMetadata Read-LocaleMetadata Write-InstallerManifest Write-VersionManifest Write-LocaleManifest } 'NewLocale' { $PackageLocale = $null $script:OldLocaleManifest = [ordered]@{} $script:OldLocaleManifest['ManifestType'] = 'locale' Read-LocaleMetadata Write-LocaleManifest } 'RemoveManifest' { # Confirm the user is sure they know what they are doing $_menu = @{ entries = @("[Y] Remove $PackageIdentifier version $PackageVersion"; '*[N] Cancel') Prompt = 'Are you sure you want to continue?' HelpText = "Manifest Versions should only be removed when necessary`n" HelpTextColor = 'Red' DefaultString = 'N' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString'] -HelpText $_menu['HelpText'] -HelpTextColor $_menu['HelpTextColor']) { 'Y' { Write-Host; continue } default { Invoke-CleanExit } } # Require that a reason for the deletion is provided do { Write-Host -ForegroundColor 'Red' $script:_returnValue.ErrorString() Write-Host -ForegroundColor 'Green' -Object '[Required] Enter the reason for removing this manifest' $script:RemovalReason = Read-Host -Prompt 'Reason' | TrimString # Check the reason for validity. The length requirements are arbitrary, but they have been set to encourage concise yet meaningful reasons if (Test-String $script:RemovalReason -MinLength 8 -MaxLength 128 -NotNull) { $script:_returnValue = [ReturnValue]::Success() } else { $script:_returnValue = [ReturnValue]::LengthError(8, 128) } } until ($script:_returnValue.StatusCode -eq [ReturnValue]::Success().StatusCode) $AppFolder = Remove-ManifestVersion $AppFolder } 'Auto' { # Set new package version $script:OldInstallerManifest['PackageVersion'] = $PackageVersion $script:OldLocaleManifest['PackageVersion'] = $PackageVersion $script:OldVersionManifest['PackageVersion'] = $PackageVersion # Update the manifest with URLs that are already there Write-Host $NewLine Write-Host 'Updating Manifest Information. This may take a while...' -ForegroundColor Blue $_NewInstallers = @(); foreach ($_Installer in $script:OldInstallerManifest.Installers) { $_Installer['InstallerUrl'] = [System.Web.HttpUtility]::UrlDecode($_Installer.InstallerUrl.Replace('+', '%2B')) $_Installer['InstallerUrl'] = $_Installer.InstallerUrl.Replace(' ', '%20') try { $script:dest = Get-InstallerFile -URI $_Installer.InstallerUrl -PackageIdentifier $PackageIdentifier -PackageVersion $PackageVersion } catch { # Here we also want to pass any exceptions through for potential debugging throw [System.Net.WebException]::new('The file could not be downloaded. Try running the script again', $_.Exception) } # Check that MSI's aren't actually WIX, and EXE's aren't NSIS, INNO or BURN if ($_Installer['InstallerType'] -in @('msi'; 'exe')) { $DetectedType = Resolve-InstallerType $script:dest if ($DetectedType -in @('msi'; 'wix'; 'nullsoft'; 'inno'; 'burn')) { $_Installer['InstallerType'] = $DetectedType } } # Get the Sha256 $_Installer['InstallerSha256'] = (Get-FileHash -Path $script:dest -Algorithm SHA256).Hash # Update the product code, if a new one exists # If a new product code doesn't exist, and the installer isn't an `.exe` file, remove the product code if it exists $MSIProductCode = $null if ([System.Environment]::OSVersion.Platform -match 'Win' -and ($script:dest).EndsWith('.msi')) { $MSIProductCode = ([string](Get-MSIProperty -Path $script:dest -Property 'ProductCode') | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } elseif ([System.Environment]::OSVersion.Platform -match 'Unix' -and (Get-Item $script:dest).Name.EndsWith('.msi')) { $MSIProductCode = ([string](file $script:dest) | Select-String -Pattern '{[A-Z0-9]{8}-([A-Z0-9]{4}-){3}[A-Z0-9]{12}}').Matches.Value } if (Test-String -not $MSIProductCode -IsNull) { $_Installer['ProductCode'] = $MSIProductCode } elseif ( ($_Installer.Keys -contains 'ProductCode') -and ($_Installer.InstallerType -in @('appx'; 'msi'; 'msix'; 'wix'; 'burn'))) { $_Installer.Remove('ProductCode') } # If the installer is msix or appx, try getting the new SignatureSha256 # If the new SignatureSha256 can't be found, remove it if it exists $NewSignatureSha256 = $null if ($_Installer.InstallerType -in @('msix', 'appx')) { if (Get-Command 'winget' -ErrorAction SilentlyContinue) { $NewSignatureSha256 = winget hash -m $script:dest | Select-String -Pattern 'SignatureSha256:' | ConvertFrom-String; if ($NewSignatureSha256.P2) { $NewSignatureSha256 = $NewSignatureSha256.P2.ToUpper() } } } if (Test-String -not $NewSignatureSha256 -IsNull) { $_Installer['SignatureSha256'] = $NewSignatureSha256 } elseif ($_Installer.Keys -contains 'SignatureSha256') { $_Installer.Remove('SignatureSha256') } # If the installer is msix or appx, try getting the new package family name # If the new package family name can't be found, remove it if it exists if ($script:dest -match '\.(msix|appx)(bundle){0,1}$') { $PackageFamilyName = Get-PackageFamilyName $script:dest if (Test-String $PackageFamilyName -MatchPattern $Patterns.FamilyName) { $_Installer['PackageFamilyName'] = $PackageFamilyName } elseif ($_NewInstaller.Keys -contains 'PackageFamilyName') { $_Installer.Remove('PackageFamilyName') } } # Remove the downloaded files Remove-Item -Path $script:dest $_NewInstallers += Restore-YamlKeyOrder $_Installer $InstallerEntryProperties -NoComments } # Write the new manifests $script:Installers = $_NewInstallers Write-LocaleManifest Write-InstallerManifest Write-VersionManifest # Remove the old manifests if ($PackageVersion -ne $LastVersion) { Remove-ManifestVersion "$AppFolder\..\$LastVersion" } } } if ($script:Option -ne 'RemoveManifest') { # If the user has winget installed, attempt to validate the manifests if (Get-Command 'winget' -ErrorAction SilentlyContinue) { winget validate $AppFolder } # If the user has sandbox enabled, request to test the manifest in the sandbox if (Get-Command 'WindowsSandbox' -ErrorAction SilentlyContinue) { # Check the settings to see if we need to display this menu switch ($ScriptSettings.TestManifestsInSandbox) { 'always' { $script:SandboxTest = '0' } 'never' { $script:SandboxTest = '1' } default { $_menu = @{ entries = @('*[Y] Yes'; '[N] No') Prompt = '[Recommended] Do you want to test your Manifest in Windows Sandbox?' DefaultString = 'Y' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $script:SandboxTest = '0' } 'N' { $script:SandboxTest = '1' } default { $script:SandboxTest = '0' } } Write-Host } } if ($script:SandboxTest -eq '0') { if (Test-Path -Path "$gitTopLevel\Tools\SandboxTest.ps1") { $SandboxScriptPath = (Resolve-Path "$gitTopLevel\Tools\SandboxTest.ps1").Path } else { while ([string]::IsNullOrWhiteSpace($SandboxScriptPath)) { Write-Host Write-Host -ForegroundColor 'Green' -Object 'SandboxTest.ps1 not found, input path' $SandboxScriptPath = Read-Host -Prompt 'SandboxTest.ps1' | TrimString } } if ($script:UsesPrerelease) { & $SandboxScriptPath -Manifest $AppFolder -Prerelease -EnableExperimentalFeatures } else { & $SandboxScriptPath -Manifest $AppFolder } } } } # If the user has git installed, request to automatically submit the PR if (Get-Command 'git' -ErrorAction SilentlyContinue) { switch ($ScriptSettings.AutoSubmitPRs) { 'always' { $PromptSubmit = '0' } 'never' { $PromptSubmit = '1' } default { $_menu = @{ entries = @('*[Y] Yes'; '[N] No') Prompt = 'Do you want to submit your PR now?' DefaultString = 'Y' } switch ( Invoke-KeypressMenu -Prompt $_menu['Prompt'] -Entries $_menu['Entries'] -DefaultString $_menu['DefaultString']) { 'Y' { $PromptSubmit = '0' } 'N' { $PromptSubmit = '1' } default { $PromptSubmit = '0' } } } } } Write-Host # If the user agreed to automatically submit the PR if ($PromptSubmit -eq '0') { # Determine what type of update should be used as the prefix for the PR switch -regex ($Option) { 'New|QuickUpdateVersion|Auto' { $AllVersions = (@($script:ExistingVersions) + @($PackageVersion)) | Sort-Object $ToNatural if ($AllVersions.Count -eq '1') { $CommitType = 'New package' } elseif ($script:PackageVersion -in $script:ExistingVersions) { $CommitType = 'Update' } elseif (($AllVersions.IndexOf($PackageVersion) + 1) -eq $AllVersions.Count) { $CommitType = 'New version' } elseif (($AllVersions.IndexOf($PackageVersion) + 1) -ne $AllVersions.Count) { $CommitType = 'Add version' } } 'EditMetadata' { $CommitType = 'Metadata' } 'NewLocale' { $CommitType = 'Locale' } 'RemoveManifest' { $CommitType = 'Remove' } } # Change the users git configuration to suppress some git messages $_previousConfig = git config --get core.safecrlf if ($_previousConfig) { git config --replace core.safecrlf false } else { git config --add core.safecrlf false } # Fetch the upstream branch, create a commit onto the detached head, and push it to a new branch git fetch upstream master --quiet git switch -d upstream/master if ($LASTEXITCODE -eq '0') { $BranchName = "$PackageIdentifier-$PackageVersion-$RunHash" # Git branch names cannot start with `.` cannot contain any of {`..`, `\`, `~`, `^`, `:`, ` `, `?`, `@{`, `[`}, and cannot end with {`/`, `.lock`, `.`} $BranchName = $BranchName -replace '[\~,\^,\:,\\,\?,\@\{,\*,\[,\s]{1,}|[.lock|/|\.]*$|^\.{1,}|\.\.', '' git add "$(Join-Path (Get-Item $AppFolder).Parent.FullName -ChildPath '*')" git commit -m "$CommitType`: $PackageIdentifier version $PackageVersion" --quiet git switch -c "$BranchName" --quiet git push --set-upstream origin "$BranchName" --quiet # If the user has the cli too if (Get-Command 'gh' -ErrorAction SilentlyContinue) { # Request the user to fill out the PR template if (Test-Path -Path "$gitTopLevel\.github\PULL_REQUEST_TEMPLATE.md") { Read-PRBody (Resolve-Path "$gitTopLevel\.github\PULL_REQUEST_TEMPLATE.md").Path } else { while ([string]::IsNullOrWhiteSpace($PRTemplate)) { Write-Host Write-Host -ForegroundColor 'Green' -Object 'PULL_REQUEST_TEMPLATE.md not found, input path' $PRTemplate = Read-Host -Prompt 'PR Template' | TrimString } Read-PRBody "$PRTemplate" } } } # Restore the user's previous git settings to ensure we don't disrupt their normal flow if ($_previousConfig) { git config --replace core.safecrlf $_previousConfig } else { git config --unset core.safecrlf } } else { Write-Host Invoke-CleanExit } [Threading.Thread]::CurrentThread.CurrentUICulture = $callingUICulture [Threading.Thread]::CurrentThread.CurrentCulture = $callingCulture # Error levels for the ReturnValue class Enum ErrorLevel { Undefined = -1 Info = 0 Warning = 1 Error = 2 Critical = 3 } # Custom class for validation and error checking # `200` should be indicative of a success # `400` should be indicative of a bad request # `500` should be indicative of an internal error / other error Class ReturnValue { [int] $StatusCode [string] $Title [string] $Message [ErrorLevel] $Severity # Default Constructor ReturnValue() { } # Overload 1; Creates a return value with only a status code and no descriptors ReturnValue( [int]$statusCode ) { $this.StatusCode = $statusCode $this.Title = '-' $this.Message = '-' $this.Severity = -1 } # Overload 2; Create a return value with all parameters defined ReturnValue( [int] $statusCode, [string] $title, [string] $message, [ErrorLevel] $severity ) { $this.StatusCode = $statusCode $this.Title = $title $this.Message = $message $this.Severity = $severity } # Static reference to a default success value [ReturnValue] static Success() { return [ReturnValue]::new(200, 'OK', 'The command completed successfully', 'Info') } # Static reference to a default internal error value [ReturnValue] static GenericError() { return [ReturnValue]::new(500, 'Internal Error', 'Value was not able to be saved successfully', 2) } # Static reference to a specific error relating to the pattern of user input [ReturnValue] static PatternError() { return [ReturnValue]::new(400, 'Invalid Pattern', 'The value entered does not match the pattern requirements defined in the manifest schema', 2) } # Static reference to a specific error relating to the length of user input [ReturnValue] static LengthError([int]$MinLength, [int]$MaxLength) { return [ReturnValue]::new(400, 'Invalid Length', "Length must be between $MinLength and $MaxLength characters", 2) } # Static reference to a specific error relating to the number of entries a user input [ReturnValue] static MaxItemsError([int]$MaxEntries) { return [ReturnValue]::new(400, 'Too many entries', "Number of entries must be less than or equal to $MaxEntries", 2) } # Returns the ReturnValue as a nicely formatted string [string] ToString() { return "[$($this.Severity)] ($($this.StatusCode)) $($this.Title) - $($this.Message)" } # Returns the ReturnValue as a nicely formatted string if the status code is not equal to 200 [string] ErrorString() { if ($this.StatusCode -eq 200) { return $null } else { return "[$($this.Severity)] $($this.Title) - $($this.Message)`n" } } } class UnmetDependencyException : Exception { UnmetDependencyException([string] $message) : base($message) {} UnmetDependencyException([string] $message, [Exception] $exception) : base($message, $exception) {} } class ManifestException : Exception { ManifestException([string] $message) : base($message) {} }