Public/Sync-IntunePortfolio.ps1

# Sync-IntunePortfolio.ps1
# Declarative portfolio sync — GitOps for Intune app management

function Sync-IntunePortfolio {
    <#
    .SYNOPSIS
    Synchronizes Intune app deployments to match a declarative portfolio YAML file.
 
    .DESCRIPTION
    Reads a YAML portfolio file defining desired Intune app state, compares it against
    current Intune deployments, and reconciles the difference. New apps are deployed,
    existing apps are skipped (unless -Force), and orphaned apps can optionally be removed.
 
    Supports -WhatIf for dry-run drift detection without making changes.
 
    .PARAMETER Path
    Path to the portfolio YAML file.
 
    .PARAMETER Tenant
    Azure AD tenant ID or name (e.g., contoso.onmicrosoft.com) for app-based auth.
 
    .PARAMETER ClientId
    Azure AD application (client) ID for app-based authentication.
 
    .PARAMETER ClientSecret
    Azure AD application client secret for app-based authentication.
 
    .PARAMETER Force
    Force redeployment of all apps, even those already present in Intune.
 
    .PARAMETER RemoveAbsent
    Remove apps from Intune that are managed by WingetIntunePublisher but not in the portfolio file.
    Without this flag, orphaned apps are reported but not removed.
 
    .EXAMPLE
    Sync-IntunePortfolio -Path ./intune-portfolio.yml -WhatIf
    Shows what changes would be made without deploying anything (drift detection).
 
    .EXAMPLE
    Sync-IntunePortfolio -Path ./intune-portfolio.yml -Tenant "contoso.onmicrosoft.com" -ClientId $id -ClientSecret $secret
    Deploys new apps from the portfolio using app-based authentication.
 
    .EXAMPLE
    Sync-IntunePortfolio -Path ./intune-portfolio.yml -RemoveAbsent
    Syncs portfolio and removes any Intune apps no longer in the YAML file.
 
    .OUTPUTS
    PSCustomObject with Deployed, Skipped, Removed, and Failed arrays.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$Path,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^[a-zA-Z0-9.\-]+$')]
        [string]$Tenant,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^[a-fA-F0-9\-]{36}$')]
        [string]$ClientId,

        [Parameter(Mandatory = $false)]
        [string]$ClientSecret,

        [Parameter(Mandatory = $false)]
        [switch]$Force,

        [Parameter(Mandatory = $false)]
        [switch]$RemoveAbsent
    )

    begin {
        $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        Write-Host "`nIntune Portfolio Sync" -ForegroundColor Cyan
        Write-Host "Portfolio: $Path" -ForegroundColor Gray
    }

    process {
        # 1. Parse portfolio file
        Write-Host "`n[1/5] Reading portfolio..." -ForegroundColor White
        try {
            $desiredApps = Read-PortfolioFile -Path $Path
        } catch {
            throw "Failed to read portfolio file: $_"
        }

        if ($Force) {
            foreach ($app in $desiredApps) {
                $app | Add-Member -NotePropertyName 'Force' -NotePropertyValue $true -Force
            }
        }

        Write-Host " Found $($desiredApps.Count) app(s) in portfolio" -ForegroundColor Gray

        # 2. Connect to Graph
        Write-Host "[2/5] Connecting to Microsoft Graph..." -ForegroundColor White
        try {
            if ($ClientId) {
                Connect-ToGraph -Tenant $Tenant -AppId $ClientId -AppSecret $ClientSecret
            } else {
                $scopes = @(
                    "DeviceManagementApps.ReadWrite.All",
                    "DeviceManagementConfiguration.ReadWrite.All",
                    "Group.ReadWrite.All",
                    "GroupMember.ReadWrite.All",
                    "openid", "profile", "email", "offline_access"
                )
                Connect-ToGraph -Scopes $scopes
            }
        } catch {
            throw "Graph authentication failed: $_"
        }
        Write-Host " Connected" -ForegroundColor Gray

        # 3. Query current Intune state
        Write-Host "[3/5] Querying Intune app inventory..." -ForegroundColor White
        try {
            $currentApps = @(Get-IntuneApplication)
        } catch {
            throw "Failed to query Intune apps: $_"
        }
        $managedCount = @($currentApps | Where-Object { $_.description -match [regex]::Escape($script:PublisherTag) }).Count
        Write-Host " Found $($currentApps.Count) total app(s), $managedCount managed by WingetIntunePublisher" -ForegroundColor Gray

        # 4. Compute diff
        Write-Host "[4/5] Computing sync plan..." -ForegroundColor White
        $diff = Compare-PortfolioState -DesiredApps $desiredApps -CurrentApps $currentApps
        Format-PortfolioReport -DiffResult $diff -RemoveAbsent:$RemoveAbsent

        # 5. Execute sync
        Write-Host "[5/5] Executing sync..." -ForegroundColor White

        $results = [PSCustomObject]@{
            Deployed = [System.Collections.Generic.List[PSCustomObject]]::new()
            Skipped  = [System.Collections.Generic.List[PSCustomObject]]::new()
            Removed  = [System.Collections.Generic.List[PSCustomObject]]::new()
            Failed   = [System.Collections.Generic.List[PSCustomObject]]::new()
        }

        # Record skipped apps
        foreach ($app in $diff.UpToDate) {
            $results.Skipped.Add([PSCustomObject]@{
                AppId  = $app.Id
                Name   = $app.Name
                Action = 'Skipped'
                Reason = 'Already deployed'
            })
        }

        # Deploy new/forced apps
        foreach ($app in $diff.ToDeploy) {
            $displayName = if ($app.Name) { $app.Name } else { $app.Id }

            if (-not $PSCmdlet.ShouldProcess($displayName, "Deploy to Intune")) {
                $results.Skipped.Add([PSCustomObject]@{
                    AppId  = $app.Id
                    Name   = $displayName
                    Action = 'Skipped'
                    Reason = 'WhatIf'
                })
                continue
            }

            Write-Host " Deploying: $($app.Id)" -ForegroundColor Green -NoNewline

            try {
                # Resolve app name from WinGet if not specified
                $appName = $app.Name
                if (-not $appName) {
                    $wingetInfo = Find-WinGetPackage -Id $app.Id
                    if ($wingetInfo) {
                        $appName = $wingetInfo.Name
                    } else {
                        $appName = $app.Id
                    }
                }

                $tempDir = [System.IO.Path]::GetTempPath()
                $sessionId = [guid]::NewGuid().ToString('N').Substring(0, 8)
                $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
                $baseTempPath = Join-Path -Path $tempDir -ChildPath 'WingetIntunePublisher'
                New-Item -Path $baseTempPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
                $basePath = Join-Path -Path $baseTempPath -ChildPath "$sessionId-$timestamp"
                New-Item -Path $basePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
                $deployParams = @{
                    AppId            = $app.Id
                    AppName          = $appName
                    BasePath         = $basePath
                    AvailableInstall = $app.AvailableInstall
                }

                if ($app.InstallGroupName) { $deployParams['InstallGroupName'] = $app.InstallGroupName }
                if ($app.UninstallGroupName) { $deployParams['UninstallGroupName'] = $app.UninstallGroupName }
                if ($app.Force) { $deployParams['Force'] = $true }
                $deployParams['Remediation'] = $app.Remediation

                Deploy-WinGetApp @deployParams

                Write-Host " ✓" -ForegroundColor Green
                $results.Deployed.Add([PSCustomObject]@{
                    AppId  = $app.Id
                    Name   = $appName
                    Action = 'Deployed'
                })
            } catch {
                Write-Host " ✗ $_" -ForegroundColor Red
                $results.Failed.Add([PSCustomObject]@{
                    AppId  = $app.Id
                    Name   = $displayName
                    Action = 'Failed'
                    Error  = $_.ToString()
                })
            } finally {
                if ($basePath -and (Test-Path $basePath)) {
                    Remove-Item -Path $basePath -Recurse -Force -ErrorAction SilentlyContinue
                }
            }
        }

        # Remove orphaned apps
        if ($RemoveAbsent -and $diff.ToRemove.Count -gt 0) {
            foreach ($orphan in $diff.ToRemove) {
                if (-not $PSCmdlet.ShouldProcess($orphan.DisplayName, "Remove from Intune")) {
                    continue
                }

                Write-Host " Removing: $($orphan.DisplayName)" -ForegroundColor Red -NoNewline
                try {
                    Remove-WingetIntuneApps -AppName $orphan.DisplayName -Confirm:$false
                    Write-Host " ✓" -ForegroundColor Red
                    $results.Removed.Add([PSCustomObject]@{
                        AppId  = $orphan.IntuneId
                        Name   = $orphan.DisplayName
                        Action = 'Removed'
                    })
                } catch {
                    Write-Host " ✗ $_" -ForegroundColor Red
                    $results.Failed.Add([PSCustomObject]@{
                        AppId  = $orphan.IntuneId
                        Name   = $orphan.DisplayName
                        Action = 'RemoveFailed'
                        Error  = $_.ToString()
                    })
                }
            }
        }
    }

    end {
        $stopwatch.Stop()
        $elapsed = $stopwatch.Elapsed

        # Summary
        Write-Host "`n═══════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host " Sync Complete ($([math]::Round($elapsed.TotalSeconds))s)" -ForegroundColor Cyan
        Write-Host " Deployed: $($results.Deployed.Count) | Skipped: $($results.Skipped.Count) | Removed: $($results.Removed.Count) | Failed: $($results.Failed.Count)" -ForegroundColor Gray
        Write-Host "═══════════════════════════════════════════`n" -ForegroundColor Cyan

        if ($results.Failed.Count -gt 0) {
            Write-Warning "$($results.Failed.Count) operation(s) failed. Review the returned results for details."
        }

        return $results
    }
}