Public/Invoke-Win32Toolkit.ps1
|
function Invoke-Win32Toolkit { <# .SYNOPSIS End-to-end Win32 app packaging automation. .DESCRIPTION Searches Winget for an application, downloads it, creates a PSADT V4 project, configures the installer, generates Intune requirement scripts, and launches a Windows Sandbox session for targeted installation documentation. .PARAMETER SearchTerm Term to search for in the Winget repository. .PARAMETER Id Winget package ID to use directly (skips search). Example: 'Git.Git' .PARAMETER TemplateName Name of the org template to load/create. Skips the interactive template picker. If the template does not exist yet, the wizard is pre-filled with this name. .PARAMETER NewTemplate Run the org template wizard, save the template, and exit without packaging any app. .PARAMETER Architecture Architecture to use for download and project creation (x64, x86, arm64). Skips the interactive architecture selection menu. .PARAMETER Force Skip the PSGallery update prompt and the project overwrite prompt. Useful for unattended / automated runs. .PARAMETER BasePath Base folder for all output (Templates, Projects, Staging, IntuneWin). If omitted, the value saved in the registry (HKCU:\Software\CloudFlow\win32-toolkit) is used; on first run you are prompted for it and the choice is saved. An explicit value overrides but is not persisted. .PARAMETER Reconfigure Re-prompt for the base folder and save the new value to the registry, ignoring any stored value. .PARAMETER RunTest Test scenario(s) to run right after the project is built and documented: 'InstallUninstall', 'Update', or both (array — scenarios run in the order given). Each runs in the configured test backend (Windows Sandbox by default, Hyper-V when configured and ready). A failed scenario is reported with a warning but does not stop packaging/publishing. .PARAMETER UpdateVersionsBack For -RunTest Update: automatically use the version N releases older than the packaged one as the update baseline (1 = the immediately previous release), so a scripted pipeline never blocks on the interactive version picker. Ignored when -UpdateSpecificVersion is also supplied. .PARAMETER UpdateSpecificVersion For -RunTest Update: use this exact older version as the update baseline. Takes precedence over -UpdateVersionsBack when both are supplied. Omit both to pick interactively. .PARAMETER PackageIntune After creating the project, package it into a .intunewin file using IntuneWinAppUtil.exe. .PARAMETER PublishIntune After packaging, upload the .intunewin file to Microsoft Intune via Graph API. Implies -PackageIntune. Requires the Microsoft.Graph.Authentication module. .PARAMETER PublishUpdate Also publish the UPDATE app: a second Intune app of the same version whose requirement rule makes it applicable only to devices that already have the app installed. Implies packaging. .PARAMETER DependsOn Dependencies to declare for this app before packaging: 'winget:<Id>', 'project:<Template>\<Name>', or 'intune:<AppGuid>' references (array). Declared dependencies install first in the test guest and are related to the Intune app at publish time. .PARAMETER DependencyType How Intune should treat the declared dependencies: 'autoInstall' (default) installs them automatically; 'detect' only requires their presence. .EXAMPLE Invoke-Win32Toolkit -Id 'Git.Git' -Architecture x64 -Force .EXAMPLE Invoke-Win32Toolkit -SearchTerm 'visual studio code' -BasePath 'D:\Packaging' .EXAMPLE Invoke-Win32Toolkit -NewTemplate -TemplateName 'Contoso' .EXAMPLE Invoke-Win32Toolkit -Id 'Git.Git' -Architecture x64 -Force -PackageIntune -PublishIntune .EXAMPLE # Fully scripted pipeline incl. both tests; the Update baseline is the previous release Invoke-Win32Toolkit -Id 'Mozilla.Firefox' -Architecture x64 -Force -RunTest InstallUninstall, Update -UpdateVersionsBack 1 -PackageIntune #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$SearchTerm, [Parameter(Mandatory = $false)] [string]$Id, [Parameter(Mandatory = $false)] [string]$TemplateName, [Parameter(Mandatory = $false)] [switch]$NewTemplate, [Parameter(Mandatory = $false)] [string]$Architecture, [Parameter(Mandatory = $false)] [switch]$Force, [Parameter(Mandatory = $false)] [string]$BasePath, [Parameter(Mandatory = $false)] [switch]$Reconfigure, [Parameter(Mandatory = $false)] [ValidateSet('InstallUninstall', 'Update')] [string[]]$RunTest, # Update-test baseline selection for -RunTest Update: pick the Nth-older release (or an exact # version) up front, so a scripted pipeline never blocks on the interactive version picker # mid-run. Omit both to keep the interactive picker. SpecificVersion wins over VersionsBack. [Parameter(Mandatory = $false)] [ValidateRange(1, 1000)] [int]$UpdateVersionsBack, [Parameter(Mandatory = $false)] [string]$UpdateSpecificVersion, [Parameter(Mandatory = $false)] [switch]$PackageIntune, [Parameter(Mandatory = $false)] [switch]$PublishIntune, # Also publish the update app (2nd app, same version, requirement-gated to installed devices). [Parameter(Mandatory = $false)] [switch]$PublishUpdate, # Apps that Intune must install BEFORE this one (e.g. a Visual C++ redistributable). # Accepts 'winget:<id>', 'project:<Template>\<Name>', 'intune:<guid>', or a bare reference. # Declared into AppConfig.json; resolved to real Intune app ids and attached as # mobileAppDependency relationships at publish time. [string[]]$DependsOn, [ValidateSet('autoInstall', 'detect')] [string]$DependencyType = 'autoInstall' ) try { Write-Host 'Winget App Downloader + PSADT Project Creator' -ForegroundColor Cyan Write-Host '===========================================' -ForegroundColor Cyan # Check if winget is installed if (-not (Test-WingetInstalled)) { throw 'Winget is not installed or not available in PATH' } # Resolve the base folder (registry-backed; prompts and saves on first run) $BasePath = Get-Win32ToolkitBasePath -BasePath $BasePath -Reconfigure:$Reconfigure Write-Host "Base folder: $BasePath" -ForegroundColor DarkGray # -NewTemplate: create/update a template then exit, without packaging anything if ($NewTemplate) { $newTpl = New-OrgTemplate -TemplateName $TemplateName -BasePath $BasePath if ($newTpl) { Write-Host "`n✓ Template '$($newTpl.TemplateName)' created successfully." -ForegroundColor Green } return } # Load (or create) org template once for this run $script:OrgTemplate = Get-OrgTemplate -TemplateName $TemplateName -BasePath $BasePath # -Id fast path: skip search entirely if ($Id) { Write-Verbose "Resolving package ID: $Id" $showOutput = winget show --id "$Id" --exact --accept-source-agreements | Out-String if ($LASTEXITCODE -ne 0 -or $showOutput -notmatch 'Found') { Write-Error "Package ID '$Id' not found in winget. Verify the ID and try again." return } $resolvedName = if ($showOutput -match '(?m)^Found\s+(.+?)\s+\[') { $matches[1].Trim() } else { $Id } $resolvedVersion = if ($showOutput -match '(?m)^\s*Version:\s*(.+)') { $matches[1].Trim() } else { 'Unknown' } $selectedApp = [PSCustomObject]@{ Name = $resolvedName Id = $Id Version = $resolvedVersion Source = 'winget' } Write-Host "`nSelected: $($selectedApp.Name) v$($selectedApp.Version)" -ForegroundColor Cyan } else { # Get search term if not provided if (-not $SearchTerm) { $SearchTerm = Read-Host 'Enter search term for applications' if ([string]::IsNullOrWhiteSpace($SearchTerm)) { throw 'Search term is required' } } # Search for apps and exclude Microsoft Store results $apps = Search-WingetApps -SearchTerm $SearchTerm $apps = $apps | Where-Object { $_.Source -ne 'msstore' } if ($apps.Count -eq 0) { Write-Warning "No applications found matching: $SearchTerm" return } Write-Host "Found $($apps.Count) applications" -ForegroundColor Green # Display apps as a numbered list with alternating row colours Write-Host '' Write-Host (" {0,-3} {1,-28} {2,-32} {3,-10} {4}" -f '#', 'Name', 'Id', 'Version', 'Source') -ForegroundColor Gray Write-Host (" " + '-' * 82) -ForegroundColor DarkGray for ($i = 0; $i -lt $apps.Count; $i++) { $color = if ($i % 2 -eq 0) { 'Cyan' } else { 'White' } Write-Host (" {0,-3} {1,-28} {2,-32} {3,-10} {4}" -f ($i + 1), $apps[$i].Name, $apps[$i].Id, $apps[$i].Version, $apps[$i].Source) -ForegroundColor $color } Write-Host '' do { $rawInput = Read-Host "Select application (1-$($apps.Count))" $parsed = 0 $valid = [int]::TryParse($rawInput.Trim(), [ref]$parsed) -and $parsed -ge 1 -and $parsed -le $apps.Count if (-not $valid) { Write-Host "Invalid selection. Please enter a number between 1 and $($apps.Count)." -ForegroundColor Red } } while (-not $valid) $selectedApp = $apps[$parsed - 1] if (-not $selectedApp) { Write-Warning 'No application selected' return } Write-Host "`nSelected: $($selectedApp.Name) v$($selectedApp.Version)" -ForegroundColor Cyan } # Get available architectures for the selected app $availableArchs = Get-WingetAppDetails -AppId $selectedApp.Id # Let user select architecture (single selection for project creation) $selectedArch = Select-Architecture -Architectures $availableArchs -AppName $selectedApp.Name -PreSelected $Architecture if ($selectedArch -eq 'all') { if ($Architecture) { Write-Warning "'-Architecture all' is not valid — defaulting to x64." $selectedArch = 'x64' } else { Write-Warning 'For project creation, please select a specific architecture.' $selectedArch = Select-Architecture -Architectures $availableArchs -AppName $selectedApp.Name } } # Create project name using AppName_Architecture_Version format $appNameClean = Sanitize-ProjectName -Name $selectedApp.Name $versionClean = Sanitize-ProjectName -Name $selectedApp.Version $archClean = Sanitize-ProjectName -Name $selectedArch $projectName = "${appNameClean}_${archClean}_${versionClean}" Write-Host "`nProject name will be: $projectName" -ForegroundColor Cyan # Ensure the template-scoped Projects tier exists before scaffolding: # <BasePath>\Projects\<Template>\<Project> $paths = Get-Win32ToolkitPaths -BasePath $BasePath $templateSeg = Sanitize-ProjectName -Name $script:OrgTemplate.TemplateName if ([string]::IsNullOrWhiteSpace($templateSeg)) { $templateSeg = 'Default' } $projectsDir = Join-Path $paths.Projects $templateSeg if (-not (Test-Path $projectsDir)) { New-Item -Path $projectsDir -ItemType Directory -Force | Out-Null } Write-Host "Template folder: $templateSeg" -ForegroundColor DarkGray # Create the PSADT project scaffold inside Projects\<Template>\ $projectCreated = Create-PSADTProject -ProjectName $projectName -ProjectPath $projectsDir -Force:$Force if ($projectCreated) { $projectFullPath = Join-Path $projectsDir $projectName $downloadPath = Join-Path $projectFullPath 'Files' if (!(Test-Path $downloadPath)) { New-Item -Path $downloadPath -ItemType Directory -Force | Out-Null } Write-Verbose 'Downloading application to project Files directory...' $downloadSuccess = Download-WingetApp -AppId $selectedApp.Id -AppName $selectedApp.Name -DownloadPath $downloadPath -Architecture $selectedArch if ($downloadSuccess) { Write-Host "`n✓ App downloaded successfully!" -ForegroundColor Green # Normalize the installer filename to AppName_arch_version.ext Write-Verbose 'Normalizing installer filename...' Rename-InstallerFile -FilesPath $downloadPath -AppName $selectedApp.Name -Version $selectedApp.Version -Architecture $selectedArch # Configure PSADT based on downloaded installer type Write-Verbose 'Configuring PSADT for installer type...' $psadtConfigured = Configure-PSADTForInstaller -ProjectPath $projectFullPath -AppInfo $selectedApp -Architecture $selectedArch # Download and apply the app-specific icon from WinGet YAML Write-Verbose 'Downloading app icon from WinGet...' Get-AppIconFromWinget -ProjectPath $projectFullPath -FilesPath $downloadPath | Out-Null if ($psadtConfigured) { Write-Host "`n✓ SUCCESS: Project created, app downloaded, and PSADT configured!" -ForegroundColor Green } else { Write-Host "`n✓ SUCCESS: Project created and app downloaded!" -ForegroundColor Green Write-Warning 'PSADT configuration had issues - please review manually' } Write-Host "Project location: $projectFullPath" -ForegroundColor Cyan Write-Host "Downloaded files: $downloadPath" -ForegroundColor Cyan # Declare Intune app dependencies (data-only). Written AFTER Configure-PSADTForInstaller so # AppConfig.json already exists, and BEFORE finalize so publishing can resolve + attach them. if ($DependsOn) { $declared = Set-Win32ToolkitAppDependency -ProjectPath $projectFullPath -DependsOn $DependsOn -DependencyType $DependencyType Write-Host "✓ Dependencies : $((@($declared) | ForEach-Object { "$($_.Source):$($_.Ref)" }) -join ', ')" -ForegroundColor Green } # Documentation capture, uninstall automation, and optional test/package/publish # (shared with the manual-app flow — see Invoke-Win32ToolkitFinalize). $finalize = @{ ProjectPath = $projectFullPath; ProjectName = $projectName; AppInfo = $selectedApp } if ($RunTest) { $finalize['RunTest'] = $RunTest } # omit when null (ValidateSet rejects $null) if ($UpdateVersionsBack) { $finalize['UpdateVersionsBack'] = $UpdateVersionsBack } if ($UpdateSpecificVersion) { $finalize['UpdateSpecificVersion'] = $UpdateSpecificVersion } Invoke-Win32ToolkitFinalize @finalize -PackageIntune:$PackageIntune -PublishIntune:$PublishIntune -PublishUpdate:$PublishUpdate } else { # The download failed, so Rename/Configure/Finalize above were all skipped. Anything the # user asked for downstream (test, package, publish, dependencies) silently did NOT happen. # Name the options they actually supplied — a failed run must never look like a published one. # This stays a WARNING: the project folder is deliberately kept so the run can be retried. Write-Warning "Download FAILED for '$($selectedApp.Id)' ($selectedArch) - no installer was placed in the project's Files folder." $skipped = [System.Collections.Generic.List[string]]::new() if ($PSBoundParameters.ContainsKey('RunTest') -and $RunTest) { $skipped.Add("-RunTest ($($RunTest -join ', ')): the package was NOT tested - no Sandbox/Hyper-V run happened.") } if ($PSBoundParameters.ContainsKey('PackageIntune') -and $PackageIntune) { $skipped.Add('-PackageIntune: NOTHING was packaged - no .intunewin file was produced.') } if ($PSBoundParameters.ContainsKey('PublishIntune') -and $PublishIntune) { $skipped.Add('-PublishIntune: NOTHING was published - no app was uploaded to Intune.') } if ($PSBoundParameters.ContainsKey('PublishUpdate') -and $PublishUpdate) { $skipped.Add('-PublishUpdate: no update app was published to Intune.') } if ($PSBoundParameters.ContainsKey('DependsOn') -and $DependsOn) { $skipped.Add("-DependsOn ($($DependsOn -join ', ')): no dependencies were declared on the project.") } if ($skipped.Count -gt 0) { Write-Warning 'The following options you passed were SKIPPED because the download failed:' foreach ($item in $skipped) { Write-Warning " * $item" } } Write-Warning "The project folder was still created: $projectFullPath" Write-Warning "Next: re-run the same command to retry a transient download failure." # Do NOT tell the user to drop an installer into Files\ and re-run: Create-PSADTProject removes # and recreates the project directory on every run, so anything placed there by hand is deleted # before the (still failing) download is retried. Supplying your own installer is the MANUAL # flow's job. Write-Warning "To package an installer you supply yourself, use the manual flow instead:" Write-Warning " New-Win32ToolkitManualApp -Name '$($selectedApp.Name)' -Version '$($selectedApp.Version)' -Architecture $selectedArch -SourcePath <path-to-your-installer>" Write-Warning "Do not copy files into '$downloadPath' and re-run this command — the project folder is recreated from scratch each run, which would delete them." } } else { Write-Error 'Failed to create PSADT project' } } catch { Write-Error "Script execution failed: $($_.Exception.Message)" } finally { # Clear the module-scoped org template so a stale value can't leak into the next # command in the same session. Runs on both success and failure. $null is the # cleared state the module initialises at load. $script:OrgTemplate = $null } } |