Public/Export-WingetOffline.ps1
|
function Export-WingetOffline { <# .SYNOPSIS Download packages for air-gapped/offline deployment. .DESCRIPTION Creates a portable offline package repository containing installers, manifests, and a self-contained deployment script. Designed for environments where target machines have no internet access. Installers are downloaded with WinGet's own Export-WinGetPackage, which picks the installer for the requested architecture, verifies its SHA-256 hash against the manifest, and saves the manifest next to it. The generated Install-Offline.ps1 reads each manifest's silent switches, so installers run unattended with the arguments their publisher documented. The output folder can be copied via USB/network share to isolated machines and deployed with Install-Offline.ps1 (works in Windows PowerShell 5.1 and 7). .PARAMETER PackageId Package IDs to download for offline use. .PARAMETER FromManifest Path to a JSON/YAML manifest of packages (e.g. from Get-WingetMachineState -Export). .PARAMETER FromInstalled Download installers for all currently installed winget-source packages. .PARAMETER OutputPath Destination folder for the offline repository. Default: .\WingetOffline .PARAMETER IncludeDependencies Also download package dependencies. .PARAMETER Architecture Preferred installer architecture: X64, X86, Arm64. Default: X64. .PARAMETER VerifyHash Reject installers whose hash does not match the manifest. Default: true. .PARAMETER Deploy Generate and include the offline deployment script. .EXAMPLE Export-WingetOffline -PackageId "Git.Git","Python.Python.3.13" -OutputPath "E:\OfflineRepo" -Deploy Downloads Git and Python installers to a USB drive folder with an install script. .EXAMPLE Export-WingetOffline -FromManifest ".\lab-packages.json" -OutputPath "\\fileserver\offline" Downloads all packages from a manifest to a network share. .EXAMPLE Export-WingetOffline -FromInstalled -OutputPath "D:\AirGap" -Deploy Full machine clone: downloads everything installed + deployment script. .NOTES Author: Matthew Bubb Microsoft Store (msstore) packages are skipped: offline Store downloads need an organizational Entra ID account. #> [CmdletBinding(DefaultParameterSetName = 'ById')] param( [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0)] [string[]]$PackageId, [Parameter(ParameterSetName = 'Manifest', Mandatory)] [ValidateScript({ Test-Path $_ })] [string]$FromManifest, [Parameter(ParameterSetName = 'Installed', Mandatory)] [switch]$FromInstalled, [string]$OutputPath = ".\WingetOffline", [switch]$IncludeDependencies, [ValidateSet('X64', 'X86', 'Arm64')] [string]$Architecture = 'X64', [bool]$VerifyHash = $true, [switch]$Deploy ) if (-not (Get-Command Microsoft.WinGet.Client\Export-WinGetPackage -ErrorAction SilentlyContinue)) { Write-Error "Export-WinGetPackage is not available. Update the module: Install-Module Microsoft.WinGet.Client -Force" return } # --- Resolve package list: objects with Id and Version ('latest' = newest) --- $packages = [System.Collections.Generic.List[PSCustomObject]]::new() switch ($PSCmdlet.ParameterSetName) { 'ById' { foreach ($id in $PackageId) { $packages.Add([PSCustomObject]@{ Id = $id; Version = 'latest' }) } } 'Manifest' { $raw = Get-Content -Path $FromManifest -Raw $entries = @() if ($FromManifest -match '\.ya?ml$') { if (Get-Module -ListAvailable -Name powershell-yaml) { Import-Module powershell-yaml -ErrorAction Stop $entries = @((ConvertFrom-Yaml $raw).packages) } else { # Minimal fallback: "- id: X" or "- X" list items $entries = @($raw -split "\r?\n" | Where-Object { $_ -match '^\s*-\s*(?:id:\s*)?([\w\.\-]+)\s*$' } | ForEach-Object { $Matches[1] }) } } else { $manifest = $raw | ConvertFrom-Json $entries = if ($manifest.packages) { @($manifest.packages) } elseif ($manifest.Packages) { @($manifest.Packages) } else { @($manifest) } } foreach ($e in $entries) { if ($e -is [string]) { $packages.Add([PSCustomObject]@{ Id = $e; Version = 'latest' }); continue } $id = if ($e.id) { $e.id } else { $e.Id } $ver = if ($e.version) { $e.version } elseif ($e.Version) { $e.Version } else { 'latest' } $src = if ($e.source) { $e.source } else { $e.Source } if ($id -and $src -ne 'msstore') { $packages.Add([PSCustomObject]@{ Id = [string]$id; Version = [string]$ver }) } } } 'Installed' { $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.Source -eq 'winget' } foreach ($p in $installed) { $packages.Add([PSCustomObject]@{ Id = $p.Id; Version = 'latest' }) } Write-Host " Exporting $($packages.Count) installed winget packages for offline use." -ForegroundColor Cyan } } if ($packages.Count -eq 0) { Write-Error "No packages to export." return } # --- Create output structure --- $repoPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) $installersDir = Join-Path $repoPath "installers" New-Item -Path $installersDir -ItemType Directory -Force | Out-Null # --- Banner --- Write-Host "" Write-Host " WingetBatch Offline Repository" -ForegroundColor Cyan Write-Host "" Write-Host " Packages: " -NoNewline -ForegroundColor DarkGray; Write-Host $packages.Count -ForegroundColor White Write-Host " Output: " -NoNewline -ForegroundColor DarkGray; Write-Host $repoPath -ForegroundColor White Write-Host " Arch: " -NoNewline -ForegroundColor DarkGray; Write-Host $Architecture -ForegroundColor White Write-Host " Verify: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($VerifyHash) { "SHA-256 (enforced by WinGet)" } else { "Skip" }) -ForegroundColor White Write-Host "" $manifestEntries = [System.Collections.Generic.List[object]]::new() $successCount = 0 $failCount = 0 $total = $packages.Count $i = 0 foreach ($pkg in $packages) { $i++ Write-Progress -Activity "Downloading offline packages" -Status "[$i/$total] $($pkg.Id)" -PercentComplete (($i / $total) * 100) Write-Host " [$i/$total] " -NoNewline -ForegroundColor DarkGray Write-Host $pkg.Id -NoNewline -ForegroundColor Cyan Write-Host "..." -NoNewline $safeName = $pkg.Id -replace '[^\w\.\-]', '_' $pkgDir = Join-Path $installersDir $safeName if (Test-Path $pkgDir) { Remove-Item $pkgDir -Recurse -Force } New-Item -Path $pkgDir -ItemType Directory -Force | Out-Null try { $params = @{ Id = $pkg.Id MatchOption = 'EqualsCaseInsensitive' Source = 'winget' DownloadDirectory = $pkgDir Architecture = $Architecture ErrorAction = 'Stop' } if ($pkg.Version -and $pkg.Version -ne 'latest') { $params['Version'] = $pkg.Version } if (-not $IncludeDependencies) { $params['SkipDependencies'] = $true } if (-not $VerifyHash) { $params['AllowHashMismatch'] = $true } $result = @(Microsoft.WinGet.Client\Export-WinGetPackage @params)[-1] if ($result -and [string]$result.Status -ne 'Ok') { throw "WinGet status: $($result.Status)" } # WinGet writes the installer plus a .yaml manifest describing it $yaml = Get-ChildItem $pkgDir -Filter *.yaml -File | Select-Object -First 1 $installer = Get-ChildItem $pkgDir -File | Where-Object { $_.Extension -ne '.yaml' } | Sort-Object Length -Descending | Select-Object -First 1 if (-not $installer) { throw "No installer was downloaded." } $yamlText = if ($yaml) { Get-Content $yaml.FullName -Raw } else { '' } $version = Get-WingetYamlValue -Yaml $yamlText -Key 'PackageVersion' $type = if ($yamlText -match '(?m)^\s*-?\s*InstallerType:\s*(\S+)') { $Matches[1].ToLowerInvariant() } else { $installer.Extension.TrimStart('.').ToLowerInvariant() } $silent = if ($yamlText -match '(?m)^\s+Silent:\s*(.+)$') { $Matches[1].Trim().Trim('"', "'") } elseif ($yamlText -match '(?m)^\s+SilentWithProgress:\s*(.+)$') { $Matches[1].Trim().Trim('"', "'") } else { $null } $hash = (Get-FileHash -Path $installer.FullName -Algorithm SHA256).Hash $sizeMb = [Math]::Round($installer.Length / 1MB, 1) $manifestEntries.Add([ordered]@{ Id = $pkg.Id Version = $version File = "installers/$safeName/$($installer.Name)" Manifest = $(if ($yaml) { "installers/$safeName/$($yaml.Name)" } else { $null }) Type = $type SilentArgs = $silent Hash = $hash Size = "${sizeMb}MB" Status = 'Downloaded' }) Write-Host " OK v$version (${sizeMb}MB, $type)" -ForegroundColor Green $successCount++ } catch { Write-Host " FAILED ($($_.Exception.Message))" -ForegroundColor Red $failCount++ $manifestEntries.Add([ordered]@{ Id = $pkg.Id; Status = 'Failed'; Error = $_.Exception.Message }) Remove-Item $pkgDir -Recurse -Force -ErrorAction SilentlyContinue } } Write-Progress -Activity "Downloading offline packages" -Completed # --- Save manifest --- $manifestFile = Join-Path $repoPath "offline_manifest.json" [ordered]@{ Created = (Get-Date).ToString('o') Hostname = $env:COMPUTERNAME Architecture = $Architecture Generator = "WingetBatch v$((Get-Module WingetBatch).Version)" Packages = $manifestEntries } | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestFile -Encoding UTF8 # --- Generate deployment script (must run on Windows PowerShell 5.1 too) --- if ($Deploy) { $deployScript = @' # WingetBatch Offline Deployment Script # Run this on the target machine from the offline repository folder. # Usage: .\Install-Offline.ps1 -All # .\Install-Offline.ps1 -PackageId Git.Git param( [switch]$All, [string[]]$PackageId ) $ErrorActionPreference = 'Continue' $repo = $PSScriptRoot $manifestPath = Join-Path $repo "offline_manifest.json" if (-not (Test-Path $manifestPath)) { Write-Error "offline_manifest.json not found. Run this from the offline repo folder." exit 1 } if (-not $All -and -not $PackageId) { Write-Host "Specify -All or -PackageId <id>." -ForegroundColor Yellow exit 1 } $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json $packages = @($manifest.Packages | Where-Object { $_.Status -eq 'Downloaded' }) # "-File" / cmd.exe callers pass "a,b" as one string $PackageId = @($PackageId | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) if ($PackageId) { $packages = @($packages | Where-Object { $PackageId -contains $_.Id }) } # Silent switches for installer technologies when the manifest has none $defaultSilent = @{ inno = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' nullsoft = '/S' burn = '/quiet /norestart' wix = '/qn /norestart' msi = '/qn /norestart' exe = '/S' } Write-Host "" Write-Host " WingetBatch Offline Deployment" -ForegroundColor Cyan Write-Host " Packages: $($packages.Count) | Source: $repo" -ForegroundColor DarkGray Write-Host "" $success = 0; $failed = 0; $reboot = $false foreach ($pkg in $packages) { $filePath = Join-Path $repo $pkg.File if (-not (Test-Path $filePath)) { Write-Host " [SKIP] $($pkg.Id) - file missing" -ForegroundColor Yellow continue } if ((Get-FileHash -Path $filePath -Algorithm SHA256).Hash -ne $pkg.Hash) { Write-Host " [SKIP] $($pkg.Id) - file hash does not match the manifest" -ForegroundColor Red $failed++ continue } Write-Host " [INSTALL] $($pkg.Id) v$($pkg.Version) ($($pkg.Type))..." -NoNewline -ForegroundColor Cyan $silent = if ($pkg.SilentArgs) { $pkg.SilentArgs } else { $defaultSilent[$pkg.Type] } try { $exitCode = 0 switch -Regex ($pkg.Type) { '^(msix|appx)$' { Add-AppxPackage -Path $filePath -ErrorAction Stop } '^(msi|wix)$' { $argsList = "/i `"$filePath`" $silent" $exitCode = (Start-Process -FilePath "msiexec.exe" -ArgumentList $argsList -Wait -PassThru).ExitCode } '^(zip|portable)$' { $dest = Join-Path $env:LOCALAPPDATA "Programs\$($pkg.Id)" New-Item -ItemType Directory -Path $dest -Force | Out-Null if ($pkg.Type -eq 'zip') { Expand-Archive -Path $filePath -DestinationPath $dest -Force } else { Copy-Item -Path $filePath -Destination $dest -Force } Write-Host " (extracted to $dest)" -NoNewline -ForegroundColor DarkGray } default { $proc = if ($silent) { Start-Process -FilePath $filePath -ArgumentList $silent -Wait -PassThru } else { Start-Process -FilePath $filePath -Wait -PassThru } $exitCode = $proc.ExitCode } } if (@(0, 3010, 1641) -notcontains $exitCode) { throw "Exit code: $exitCode" } if ($exitCode -ne 0) { $reboot = $true } Write-Host " OK" -ForegroundColor Green $success++ } catch { Write-Host " FAILED ($($_.Exception.Message))" -ForegroundColor Red $failed++ } } Write-Host "" Write-Host " Complete: $success installed, $failed failed." -ForegroundColor $(if ($failed -eq 0) { 'Green' } else { 'Yellow' }) if ($reboot) { Write-Host " A restart is required to finish at least one installation." -ForegroundColor Yellow } Write-Host "" '@ $deployPath = Join-Path $repoPath "Install-Offline.ps1" $deployScript | Set-Content -Path $deployPath -Encoding UTF8 } # --- Summary --- Write-Host "" Write-Host " Offline repository complete" -ForegroundColor White Write-Host " Downloaded: $successCount | Failed: $failCount | Total: $total" -ForegroundColor White Write-Host " Path: $repoPath" -ForegroundColor White if ($Deploy) { Write-Host " Deploy: .\Install-Offline.ps1 -All" -ForegroundColor White } Write-Host "" return [PSCustomObject]@{ OutputPath = $repoPath Downloaded = $successCount Failed = $failCount Total = $total ManifestFile = $manifestFile DeployScript = if ($Deploy) { (Join-Path $repoPath "Install-Offline.ps1") } else { $null } } } |