Public/Invoke-WingetIntunePublisher.ps1

function Invoke-WingetIntunePublisher {
    <#
.SYNOPSIS
Deploy Winget applications to Microsoft Intune in one run.
.DESCRIPTION
Handles module prerequisite checks, connects to Microsoft Graph (interactive or app auth),
optionally prompts for app selection, and orchestrates deployment via Deploy-WinGetApp.
.PARAMETER appid
One or more Winget App IDs to deploy.
.PARAMETER appname
Optional display names aligned with App IDs (falls back to Winget lookup or AppId).
.PARAMETER tenant
Tenant ID/Name for app-based authentication.
.PARAMETER clientid
Azure AD app registration Client ID for app-based authentication.
.PARAMETER clientsecret
Azure AD app registration Client Secret for app-based authentication.
.PARAMETER installgroupname
Optional custom install group name (auto-generated if omitted).
.PARAMETER uninstallgroupname
Optional custom uninstall group name (auto-generated if omitted).
.PARAMETER availableinstall
Make app available to User, Device, Both, or None (default User).
.PARAMETER Force
Force deployment even if the app already exists in Intune.
#>

    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateScript({
            foreach ($id in $_) {
                if ([string]::IsNullOrWhiteSpace($id)) {
                    throw "App ID cannot be empty or whitespace"
                }
                if ($id.Length -gt 255) {
                    throw "App ID '$id' exceeds 255 characters"
                }
                if ($id -match '[<>:"|?*\\]') {
                    throw "App ID '$id' contains invalid characters"
                }
            }
            $true
        })]
        [string[]]$appid,

        [ValidateNotNullOrEmpty()]
        [string[]]$appname = @(),

        [ValidatePattern('^[a-zA-Z0-9.-]+$')]
        [string]$tenant = "",

        [ValidatePattern('^[a-fA-F0-9-]{36}$|^$')]
        [string]$clientid = "",

        [string]$clientsecret = "",
        [string]$installgroupname = "",
        [string]$uninstallgroupname = "",

        [ValidateSet('User', 'Device', 'Both', 'None')]
        [string]$availableinstall = "User",

        [switch]$Force
    )

    if (-not $availableinstall) {
        $availableinstall = "None"
    }

    # Use cross-platform temp path
    $tempDir = [System.IO.Path]::GetTempPath()
    $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $sessionId = [guid]::NewGuid().ToString('N').Substring(0, 8)
    $global:LogFile = Join-Path -Path $tempDir -ChildPath "intune-$timestamp.log"
    $LogFile2 = Join-Path -Path $tempDir -ChildPath "intuneauto-$timestamp.log"

    try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
    Start-Transcript -Path $LogFile2 | Out-Null

    # Use system temp directory instead of hardcoded C:\temp
    $baseTempPath = Join-Path -Path $tempDir -ChildPath "WingetIntunePublisher"
    New-Item -Path $baseTempPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
    Write-Verbose "Created base temp directory: $baseTempPath"

    $path = Join-Path -Path $baseTempPath -ChildPath "$sessionId-$timestamp"
    New-Item -Path $path -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
    Write-Verbose "Created session temp directory: $path"

    @(
        'Microsoft.Graph.Authentication'
        'SvRooij.ContentPrep.Cmdlet'
    ) | ForEach-Object {
        Assert-ModuleInstalled -ModuleName $_
    }

    Install-WingetIfNeeded

    Write-Verbose "Connecting to Microsoft Graph"

    if ($clientid -and $clientsecret -and $tenant) {
        Connect-ToGraph -Tenant $tenant -AppId $clientid -AppSecret $clientsecret
        Write-Verbose "Graph Connection Established"
    } else {
        Connect-ToGraph -Scopes @("DeviceManagementApps.ReadWrite.All", "DeviceManagementConfiguration.ReadWrite.All", "Group.ReadWrite.All", "GroupMember.ReadWrite.All", "openid", "profile", "email", "offline_access")
    }
    Write-Verbose "Graph connection established"

    # Resolve names and correct casing for each appid
    # Use List<T> for better performance instead of array +=
    $packs = [System.Collections.Generic.List[object]]::new()

    for ($i = 0; $i -lt $appid.Count; $i++) {
        $resolvedName = if ($appname.Count -gt $i -and $appname[$i]) {
            $appname[$i]
        } elseif ($appname.Count -eq 1 -and $appname[0]) {
            $appname[0]
        } else {
            $null
        }

        $correctedId = $appid[$i]  # Start with the original ID

        if (Get-Command Find-WinGetPackage -ErrorAction SilentlyContinue) {
            try {
                Write-Host "Resolving package info for: $($appid[$i])" -ForegroundColor Gray

                # Try exact match first
                $pkg = Find-WinGetPackage -Id $appid[$i] -Exact -AcceptSourceAgreement -ErrorAction SilentlyContinue 2>$null

                # If exact match fails, try finding case-insensitive match
                if (-not $pkg) {
                    Write-Host " Exact match failed, trying case-insensitive search..." -ForegroundColor Gray
                    $allPkgs = Find-WinGetPackage -Id $appid[$i] -AcceptSourceAgreement -ErrorAction SilentlyContinue 2>$null

                    if ($allPkgs) {
                        Write-Host " Found $($allPkgs.Count) potential matches" -ForegroundColor Gray
                        $pkg = $allPkgs | Where-Object { $_.Id -ieq $appid[$i] } | Select-Object -First 1

                        if (-not $pkg -and $allPkgs.Count -gt 0) {
                            # If no case-insensitive match, show what was found and use first result
                            Write-Host " Available packages:" -ForegroundColor Yellow
                            $allPkgs | Select-Object -First 5 | ForEach-Object { Write-Host " - $($_.Id): $($_.Name)" -ForegroundColor Yellow }
                            $pkg = $allPkgs[0]
                        }
                    }
                }

                if ($pkg) {
                    Write-Host " Found package: $($pkg.Id)" -ForegroundColor Green

                    # Auto-correct the App ID casing
                    if ($pkg.Id -ne $appid[$i]) {
                        Write-Host "Auto-correcting App ID casing: '$($appid[$i])' → '$($pkg.Id)'" -ForegroundColor Cyan
                        $correctedId = $pkg.Id
                    }

                    # Resolve display name if not already provided
                    if (-not $resolvedName -and $pkg.Name) {
                        $resolvedName = $pkg.Name
                        Write-Host " Resolved name: $resolvedName" -ForegroundColor Green
                    }
                } else {
                    Write-Host " No package found for ID: $($appid[$i])" -ForegroundColor Yellow
                }
            } catch {
                Write-Warning "Failed to resolve package info for $($appid[$i]): $_"
            }
        }

        if (-not $resolvedName) {
            Write-Warning "Could not resolve display name for '$correctedId'. Using App ID as display name."
            $resolvedName = $correctedId
        }

        $packs.Add([pscustomobject]@{
            Id   = $correctedId.Trim()
            Name = $resolvedName.Trim()
        })
    }

    # Deploy apps with error handling and result tracking
    $deploymentResults = [System.Collections.Generic.List[object]]::new()

    foreach ($pack in $packs) {
        try {
            Write-Host "`nDeploying: $($pack.Name) ($($pack.Id))" -ForegroundColor Cyan

            Deploy-WinGetApp `
                -AppId $pack.Id.Trim() `
                -AppName $pack.Name.Trim() `
                -BasePath $path `
                -InstallGroupName $installgroupname `
                -UninstallGroupName $uninstallgroupname `
                -AvailableInstall $availableinstall `
                -Force:$Force `
                -ErrorAction Stop

            $deploymentResults.Add([PSCustomObject]@{
                AppId = $pack.Id
                AppName = $pack.Name
                Status = 'Success'
                Error = $null
            })

            Write-Host "✓ Successfully deployed: $($pack.Name)" -ForegroundColor Green
        }
        catch {
            $errorMessage = $_.Exception.Message
            Write-Error "✗ Failed to deploy $($pack.Name): $errorMessage"
            Write-Verbose "Deployment failed for $($pack.Name): $errorMessage"

            $deploymentResults.Add([PSCustomObject]@{
                AppId = $pack.Id
                AppName = $pack.Name
                Status = 'Failed'
                Error = $errorMessage
            })
        }
    }

    # Display summary
    Write-Host "`n========== Deployment Summary ==========" -ForegroundColor Cyan
    $successCount = ($deploymentResults | Where-Object Status -eq 'Success').Count
    $failedCount = ($deploymentResults | Where-Object Status -eq 'Failed').Count

    Write-Host "Total apps: $($deploymentResults.Count)" -ForegroundColor White
    Write-Host "Successful: $successCount" -ForegroundColor Green
    Write-Host "Failed: $failedCount" -ForegroundColor $(if ($failedCount -gt 0) { 'Red' } else { 'Gray' })

    if ($failedCount -gt 0) {
        Write-Host "`nFailed deployments:" -ForegroundColor Red
        $deploymentResults | Where-Object Status -eq 'Failed' | ForEach-Object {
            Write-Host " - $($_.AppName): $($_.Error)" -ForegroundColor Red
        }
    }

    Disconnect-MgGraph | Out-Null
    Stop-Transcript | Out-Null

    # Return results for pipeline processing
    return $deploymentResults
}