Public/Invoke-TenantLensSnapshot.ps1

function Invoke-TenantLensSnapshot {
    <#
    .SYNOPSIS
        Collects the tenant configuration into a versionable snapshot folder.
    .DESCRIPTION
        Runs every collector (or the subset given via -Area), writes one JSON
        file per area plus a manifest.json with SHA-256 hashes. Areas whose
        Graph scopes are missing are skipped and recorded as skipped - never
        silently left out.
    .EXAMPLE
        Invoke-TenantLensSnapshot -OutputPath .\out
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$OutputPath,

        # Collector names to run (default: all).
        [string[]]$Area,

        # Override the tenant label used for the snapshot folder name.
        [string]$TenantName
    )

    $context = Get-MgContext
    if (-not $context) { throw 'Not connected to Microsoft Graph. Run Connect-TenantLens first.' }

    $collectors = @(Get-TLCollector -Name $Area)
    if ($collectors.Count -eq 0) { throw 'No collectors matched the requested areas.' }

    # Tenant label for the folder name
    $tenantLabel = $TenantName
    if (-not $tenantLabel) {
        try {
            $organization = Invoke-TLGraphRequest -Uri '/v1.0/organization?$select=id,displayName,verifiedDomains' -All |
                Select-Object -First 1
            $defaultDomain = @($organization.verifiedDomains | Where-Object { $_.isDefault }) | Select-Object -First 1
            if ($defaultDomain) { $tenantLabel = ([string]$defaultDomain.name -split '\.')[0] }
            elseif ($organization.displayName) { $tenantLabel = [string]$organization.displayName }
        }
        catch {
            Write-Verbose ("Could not resolve the tenant name: {0}" -f $_.Exception.Message)
        }
    }
    if (-not $tenantLabel) { $tenantLabel = [string]$context.TenantId }
    $tenantLabel = ($tenantLabel -replace '[^\w\.-]', '-').Trim('-').ToLowerInvariant()

    if (-not (Test-Path -Path $OutputPath)) {
        New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
    }
    $runFolder = Join-Path -Path (Resolve-Path -Path $OutputPath).Path -ChildPath ("{0}_{1}" -f $tenantLabel, (Get-Date -Format 'yyyy-MM-dd_HHmm'))
    $snapshotDir = Join-Path -Path $runFolder -ChildPath 'snapshot'
    New-Item -Path $snapshotDir -ItemType Directory -Force | Out-Null

    $contextScopes = @($context.Scopes | Where-Object { $_ })
    $areaInfos = [ordered]@{}
    $collected = [System.Collections.Generic.List[string]]::new()
    $skipped = [System.Collections.Generic.List[string]]::new()

    foreach ($collector in $collectors) {
        $result = [ordered]@{
            area           = $collector.Name
            apiVersion     = $collector.ApiVersion
            collectedAtUtc = [DateTime]::UtcNow.ToString('o')
            skipped        = $false
            skipReason     = $null
            errors         = @()
            data           = $null
        }

        # Proactive scope check for delegated auth; app-only roles are not always
        # listed in the context, so app-only just tries and reports failures.
        $missingScopes = @($collector.RequiredScopes | Where-Object { $contextScopes -notcontains $_ })
        if ($missingScopes.Count -gt 0 -and $context.AuthType -ne 'AppOnly') {
            $result['skipped'] = $true
            $result['skipReason'] = 'Missing Graph scopes: ' + ($missingScopes -join ', ')
            Write-Warning ("Area '{0}' skipped - {1}" -f $collector.Name, $result['skipReason'])
        }
        else {
            Write-Verbose ("Collecting area '{0}' ({1})..." -f $collector.Name, $collector.ApiVersion)
            try {
                $result['data'] = & $collector.Collect
            }
            catch {
                $result['skipped'] = $true
                $result['skipReason'] = 'Collection failed: ' + $_.Exception.Message
                $result['errors'] = @([string]$_.Exception.Message)
                Write-Warning ("Area '{0}' failed: {1}" -f $collector.Name, $_.Exception.Message)
            }
        }

        $filePath = Join-Path -Path $snapshotDir -ChildPath ($collector.FileName + '.json')
        Write-TLFile -Path $filePath -Content (ConvertTo-Json -InputObject $result -Depth 32)

        $areaInfos[$collector.Name] = [ordered]@{
            skipped    = [bool]$result['skipped']
            skipReason = $result['skipReason']
            file       = ($collector.FileName + '.json')
        }
        if ($result['skipped']) { $skipped.Add($collector.Name) } else { $collected.Add($collector.Name) }
    }

    Write-TLManifest -SnapshotPath $snapshotDir -Metadata @{
        Tenant   = [ordered]@{
            id       = [string]$context.TenantId
            label    = $tenantLabel
            authType = [string]$context.AuthType
        }
        Scopes   = $contextScopes
        Areas    = $areaInfos
        Redacted = $false
    } | Out-Null

    Write-Verbose ("Snapshot written to '{0}' ({1} areas collected, {2} skipped)." -f $runFolder, $collected.Count, $skipped.Count)

    [pscustomobject]@{
        Path         = $runFolder
        SnapshotPath = $snapshotDir
        Tenant       = $tenantLabel
        Areas        = @($collected)
        SkippedAreas = @($skipped)
    }
}