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 government, defense, and enterprise air-gapped environments where target machines have no internet access. The output folder can be copied via USB/network share to isolated machines and deployed with the included Install-Offline.ps1 script. .PARAMETER PackageId Package IDs to download for offline use. .PARAMETER FromManifest Path to a JSON/YAML manifest of packages to download. .PARAMETER FromInstalled Download installers for all currently installed packages (full machine clone). .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 Verify SHA-256 hashes after download. Default: true. .PARAMETER Deploy Generate and include the offline deployment script. .EXAMPLE Export-WingetOffline -PackageId "Git.Git","Python.Python.3.12" -OutputPath "E:\OfflineRepo" Downloads Git and Python installers to a USB drive folder. .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 Downloads use winget's native download capability where available, with fallback to direct URL fetching from manifest installer URLs. #> [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 ) # --- Resolve package list --- $packages = @() switch ($PSCmdlet.ParameterSetName) { 'ById' { $packages = $PackageId } 'Manifest' { $raw = Get-Content -Path $FromManifest -Raw if ($FromManifest -match '\.ya?ml$') { # Simple YAML parsing for package lists $packages = $raw.Split("`n") | Where-Object { $_ -match '^\s*-\s*(.+)' } | ForEach-Object { $Matches[1].Trim() } } else { $manifest = $raw | ConvertFrom-Json $packages = $manifest.packages ?? $manifest.Packages ?? $manifest if ($packages -is [array] -and $packages[0].Id) { $packages = $packages | ForEach-Object { $_.Id } } } } 'Installed' { $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue $packages = @($installed | ForEach-Object { $_.Id }) Write-Host " Exporting $($packages.Count) installed packages for offline use." -ForegroundColor Cyan } } if ($packages.Count -eq 0) { Write-Error "No packages to export." return } # --- Create output structure --- $repoPath = (Resolve-Path $OutputPath -ErrorAction SilentlyContinue)?.Path ?? $OutputPath $installersDir = Join-Path $repoPath "installers" $manifestsDir = Join-Path $repoPath "manifests" New-Item -Path $repoPath -ItemType Directory -Force | Out-Null New-Item -Path $installersDir -ItemType Directory -Force | Out-Null New-Item -Path $manifestsDir -ItemType Directory -Force | Out-Null # --- Banner --- Write-Host "" Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host " ║ WingetBatch Offline Repository ║" -ForegroundColor Cyan Write-Host " ╚══════════════════════════════════════════════════════╝" -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" } else { "Skip" }) -ForegroundColor White Write-Host "" # --- Download packages --- $headers = @{ 'User-Agent' = 'WingetBatch-Offline' } $token = Get-WingetBatchGitHubToken -ErrorAction SilentlyContinue if ($token) { $headers['Authorization'] = "Bearer $token" } $manifest = @{ Created = (Get-Date -ToString 'o') Hostname = $env:COMPUTERNAME Architecture = $Architecture Packages = @() } $successCount = 0 $failCount = 0 $total = $packages.Count $i = 0 foreach ($pkgId in $packages) { $i++ Write-Progress -Activity "Downloading offline packages" -Status "[$i/$total] $pkgId" -PercentComplete (($i / $total) * 100) Write-Host " [$i/$total] " -NoNewline -ForegroundColor DarkGray Write-Host $pkgId -NoNewline -ForegroundColor Cyan Write-Host "..." -NoNewline try { # Get installer URL from winget-pkgs manifest $idParts = $pkgId.Split('.') $publisher = $idParts[0] $firstLetter = $publisher[0].ToString().ToLower() $packagePath = $idParts -join '/' $apiBase = "https://api.github.com/repos/microsoft/winget-pkgs/contents" $manifestDir = "$apiBase/manifests/$firstLetter/$packagePath" $versions = Invoke-RestMethod -Uri $manifestDir -Headers $headers -ErrorAction Stop $latest = $versions | Sort-Object { [version]($_.name -replace '[^0-9.]', '') } -ErrorAction SilentlyContinue | Select-Object -Last 1 if (-not $latest) { $latest = $versions | Select-Object -Last 1 } # Get installer manifest $versionFiles = Invoke-RestMethod -Uri $latest.url -Headers $headers -ErrorAction Stop $installerFile = $versionFiles | Where-Object { $_.name -match '\.installer\.yaml$' } | Select-Object -First 1 $downloadUrl = $null $expectedHash = $null $installerType = 'exe' if ($installerFile) { $content = Invoke-RestMethod -Uri $installerFile.url -Headers $headers -ErrorAction Stop $rawContent = $content.content if ($content.encoding -eq 'base64') { $rawContent = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($rawContent)) } # Parse installer URL for target architecture $archPattern = "Architecture:\s*$Architecture" if ($rawContent -match "InstallerUrl:\s*(.+)" ) { $downloadUrl = $Matches[1].Trim() } if ($rawContent -match "InstallerSha256:\s*([A-Fa-f0-9]{64})") { $expectedHash = $Matches[1] } if ($rawContent -match "InstallerType:\s*(\w+)") { $installerType = $Matches[1] } # Save manifest $manifestPath = Join-Path $manifestsDir "$pkgId.yaml" $rawContent | Set-Content -Path $manifestPath -Encoding UTF8 } if (-not $downloadUrl) { Write-Host " ⚠ (no installer URL found)" -ForegroundColor Yellow $failCount++ $manifest.Packages += @{ Id = $pkgId; Version = $latest.name; Status = 'NoUrl' } continue } # Download installer $ext = switch ($installerType) { 'msix' { '.msix' } 'msi' { '.msi' } 'appx' { '.appx' } default { '.exe' } } $fileName = "$($pkgId -replace '[^a-zA-Z0-9.]', '_')_$($latest.name)$ext" $filePath = Join-Path $installersDir $fileName Invoke-WebRequest -Uri $downloadUrl -OutFile $filePath -UseBasicParsing -ErrorAction Stop # Verify hash $hashOk = $true if ($VerifyHash -and $expectedHash) { $actualHash = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash $hashOk = $actualHash -eq $expectedHash if (-not $hashOk) { Write-Host " ✗ HASH MISMATCH" -ForegroundColor Red Remove-Item $filePath -Force -ErrorAction SilentlyContinue $failCount++ $manifest.Packages += @{ Id = $pkgId; Version = $latest.name; Status = 'HashMismatch' } continue } } $fileSize = [Math]::Round((Get-Item $filePath).Length / 1MB, 1) Write-Host " ✓ (${fileSize}MB)" -ForegroundColor Green $successCount++ $manifest.Packages += @{ Id = $pkgId; Version = $latest.name; File = $fileName Size = "${fileSize}MB"; Hash = $expectedHash; Type = $installerType Status = 'Downloaded' } } catch { Write-Host " ✗ ($($_.Exception.Message))" -ForegroundColor Red $failCount++ $manifest.Packages += @{ Id = $pkgId; Status = 'Failed'; Error = $_.Exception.Message } } } Write-Progress -Activity "Downloading offline packages" -Completed # --- Save manifest --- $manifestFile = Join-Path $repoPath "offline_manifest.json" $manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestFile -Encoding UTF8 # --- Generate deployment script --- if ($Deploy) { $deployScript = @' # WingetBatch Offline Deployment Script # Run this on the air-gapped target machine # Usage: .\Install-Offline.ps1 [-All] [-PackageId "Git.Git"] param( [switch]$All, [string[]]$PackageId ) $ErrorActionPreference = 'Continue' $scriptDir = $PSScriptRoot $manifestPath = Join-Path $scriptDir "offline_manifest.json" $installersDir = Join-Path $scriptDir "installers" if (-not (Test-Path $manifestPath)) { Write-Error "offline_manifest.json not found. Run this from the offline repo folder." exit 1 } $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json $packages = $manifest.Packages | Where-Object { $_.Status -eq 'Downloaded' } if ($PackageId) { $packages = $packages | Where-Object { $_.Id -in $PackageId } } Write-Host "" Write-Host " WingetBatch Offline Deployment" -ForegroundColor Cyan Write-Host " Packages: $($packages.Count) | Source: $scriptDir" -ForegroundColor DarkGray Write-Host "" $success = 0; $failed = 0 foreach ($pkg in $packages) { $filePath = Join-Path $installersDir $pkg.File if (-not (Test-Path $filePath)) { Write-Host " [SKIP] $($pkg.Id) - file missing" -ForegroundColor Yellow continue } Write-Host " [INSTALL] $($pkg.Id) v$($pkg.Version)..." -NoNewline -ForegroundColor Cyan try { $args = switch ($pkg.Type) { 'msi' { "/i `"$filePath`" /qn /norestart" } 'msix' { "" } default { "/S /silent /quiet" } } if ($pkg.Type -eq 'msix') { Add-AppxPackage -Path $filePath -ErrorAction Stop } else { $proc = Start-Process -FilePath $filePath -ArgumentList $args -Wait -PassThru -NoNewWindow if ($proc.ExitCode -notin @(0, 3010, 1641)) { throw "Exit code: $($proc.ExitCode)" } } 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' }) Write-Host "" '@ $deployPath = Join-Path $repoPath "Install-Offline.ps1" $deployScript | Set-Content -Path $deployPath -Encoding UTF8 } # --- Summary --- Write-Host "" Write-Host " ┌─────────────────────────────────────────────────────┐" -ForegroundColor White Write-Host " │ Offline Repository Complete │" -ForegroundColor White Write-Host " ├─────────────────────────────────────────────────────┤" -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 " └─────────────────────────────────────────────────────┘" -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 } } } |