Public/Invoke-Raidiness.ps1

function Invoke-Raidiness {
    <#
    .SYNOPSIS
        Collects read-only evidence from the signed-in tenant into JSON files —
        Microsoft Graph by default, optional modules on request.

    .DESCRIPTION
        -Modules picks what a run covers:

          MsGraph the core Graph collection (default; needs Connect-Raidiness)
          ExchangeOnline Exchange Online & Purview — Connect-ExchangeOnline + Connect-IPPSSession
          Teams Microsoft Teams — Connect-MicrosoftTeams
          SharePoint SharePoint Online admin — Connect-SPOService / PnP
          PowerPlatform Power Platform & Copilot Studio — Add-PowerAppsAccount
          All all of the above, one after the other

        Every module signs in on its own, interactively, and only ever reads.
        Before each sign-in the run says what will be read and which role it
        takes and waits for Enter (S skips; -NoPrompt runs straight through).
        A module that cannot run (missing PowerShell module, declined
        sign-in, missing role) is skipped with a note — the rest of the run
        continues and the report marks its checks "not measured".

        -Path collects into a new run folder named for the moment it started,
        so runs pile up next to each other instead of overwriting; -OutputPath
        collects into one directory you name, which is what earlier versions
        did and what a scripted pipeline may still want.

        The core Graph evidence is written as <data>/<collector.key>.json in
        exactly the shape the assessment engine's measure() functions are
        unit-tested against (paged value-merges, single objects, CSV rows);
        module results land as <data>/raidiness-<module>.json. Whatever could
        not be collected, and why, is written next to them as
        raidiness-run.json and repeated in the run log.

    .PARAMETER Modules
        Which modules to run (default: MsGraph). Repeatable, or All.

    .PARAMETER TenantId
        Your tenant id or default domain — the optional modules sign in with
        it; taken from the Graph session when omitted. Give the
        <name>.onmicrosoft.com form when you include SharePoint: its admin
        URL needs the tenant name, which is otherwise looked up once from
        /organization.

    .PARAMETER Path
        Where the run folders live. A new <timestamp> folder is created inside
        it for this run, with the evidence in its data/ subfolder.
        Default: Raidiness in the current user's Documents folder. This avoids
        requiring administrator rights when PowerShell starts in a protected
        working directory such as C:\Windows\System32.

    .PARAMETER OutputPath
        One directory for the evidence files, chosen by you and reused on
        every run. The form earlier versions used. Default: ./raidiness-data

    .PARAMETER IncludeSlow
        Also run Graph collectors marked slow in the manifest.

    .PARAMETER Collector
        Collect only the named Graph collector keys (repeatable), e.g. a re-try.

    .PARAMETER MaxPage
        Stop a paged Graph source after this many pages and record it as
        truncated (default 200), instead of following @odata.nextLink forever.

    .PARAMETER GraphRequestTimeoutSeconds
        Maximum time, in seconds, for each Microsoft Graph request. A timed-out
        collector is recorded as skipped and collection continues (default 100).

    .PARAMETER NoPrompt
        Do not pause before each module sign-in (for an unattended run).

    .PARAMETER Quiet
        No progress bars and no per-collector lines; warnings and the log stay.

    .PARAMETER HashUpns
        Let the module scripts replace user names and e-mail addresses with
        hashes in their results (where a script supports it). The Graph
        evidence is not affected.

    .EXAMPLE
        Connect-Raidiness
        Invoke-Raidiness -Modules All -TenantId contoso.onmicrosoft.com
        Export-RaidinessReport -Customer 'Contoso Ltd'
    #>

    [CmdletBinding(DefaultParameterSetName = 'RunFolder')]
    param(
        [ValidateSet('All', 'MsGraph', 'ExchangeOnline', 'Teams', 'SharePoint', 'PowerPlatform')]
        [string[]] $Modules = @('MsGraph'),
        [string] $TenantId,

        [Parameter(ParameterSetName = 'RunFolder')]
        [string] $Path = (Get-RaidinessDefaultRunPath),

        [Parameter(ParameterSetName = 'Legacy', Mandatory = $true)]
        [string] $OutputPath,

        [switch] $IncludeSlow,
        [string[]] $Collector,
        [int] $MaxPage = 200,
        [ValidateRange(1, 2147483647)]
        [int] $GraphRequestTimeoutSeconds = 100,
        [switch] $NoPrompt,
        [switch] $Quiet,
        [switch] $HashUpns
    )

    $ErrorActionPreference = 'Stop'

    $selected = Get-RaidinessModuleCatalog -Modules $Modules
    $context = Get-MgContext
    if (($selected.Name -contains 'MsGraph') -and -not $context) {
        throw 'Not connected to Microsoft Graph. Run Connect-Raidiness first (or leave MsGraph out of -Modules).'
    }

    # The optional modules sign in with the tenant; resolve it once, up front,
    # so a run that cannot sign in anywhere fails before the first prompt.
    $tenant = $null
    if (@($selected | Where-Object { $_.Script }).Count -gt 0) {
        $tenant = Resolve-RaidinessTenant -TenantId $TenantId
    }

    $run = $null
    if ($PSCmdlet.ParameterSetName -eq 'RunFolder') {
        $run = Initialize-RaidinessRunFolder -Path $Path -TenantName ($tenant ? $tenant.TenantName : $null)
        $dataPath = $run.Data
        Open-RaidinessLog -Path $run.Root | Out-Null
    }
    else {
        $dataPath = $OutputPath
    }

    Write-Host "Raidiness — read-only collection: $($selected.Title -join ', ')." -ForegroundColor Cyan
    Write-Host 'Each module signs in on its own and only reads. Nothing in your tenant will be changed.' -ForegroundColor Cyan

    $summary = [System.Collections.Generic.List[object]]::new()
    $skipped = [System.Collections.Generic.List[object]]::new()
    $notes = [System.Collections.Generic.List[string]]::new()
    $step = 0
    try {
        foreach ($module in $selected) {
            $step++
            Write-RaidinessProgress -Id 1 -Quiet:$Quiet -Activity 'Raidiness checkup' `
                -Status "$($module.Title) ($step of $($selected.Count))" `
                -PercentComplete ([int](100 * ($step - 1) / [math]::Max($selected.Count, 1)))

            Write-Host ''
            Write-Host "[$step/$($selected.Count)] $($module.Title)" -ForegroundColor Cyan
            Write-Host " reads: $($module.Reads)"
            Write-Host " sign-in: $($module.SignIn)"
            Write-Host " role: $($module.Role)"

            $missing = @($module.RequiredModules | Where-Object { -not (Get-Module -ListAvailable -Name $_ | Select-Object -First 1) })
            if ($missing.Count -gt 0) {
                $reason = "PowerShell module(s) not installed: $($missing -join ', ') (Install-Module $($missing[0]) -Scope CurrentUser)"
                Write-Warning "Skipped $($module.Title): $reason"
                Write-RaidinessLog -Level warn -Phase 'module' -Source $module.Name -Message "skipped: $reason"
                $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'skipped'; Note = $reason })
                $skipped.Add([pscustomobject]@{ Key = $module.Name; Kind = 'module'; Reason = $reason })
                continue
            }

            if ($module.Name -eq 'SharePoint' -and -not $tenant.TenantName) {
                $reason = 'tenant name unknown — the SharePoint admin URL is <name>-admin.sharepoint.com; pass -TenantId <name>.onmicrosoft.com'
                Write-Warning "Skipped $($module.Title): $reason"
                Write-RaidinessLog -Level warn -Phase 'module' -Source $module.Name -Message "skipped: $reason"
                $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'skipped'; Note = $reason })
                $skipped.Add([pscustomobject]@{ Key = $module.Name; Kind = 'module'; Reason = $reason })
                continue
            }

            if ($module.Script -and -not $NoPrompt) {
                $answer = Read-Host 'Press Enter to continue, or S to skip'
                if ("$answer".Trim() -like 's*') {
                    Write-Host " skipped $($module.Title) at your request." -ForegroundColor Yellow
                    Write-RaidinessLog -Phase 'module' -Source $module.Name -Message 'skipped: declined at the prompt'
                    $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'skipped'; Note = 'declined at the prompt' })
                    $skipped.Add([pscustomobject]@{ Key = $module.Name; Kind = 'module'; Reason = 'declined at the prompt' })
                    continue
                }
            }

            try {
                if ($module.Name -eq 'MsGraph') {
                    $graphResult = Invoke-RaidinessGraphCollect -OutputPath $dataPath -IncludeSlow:$IncludeSlow -Collector $Collector -MaxPage $MaxPage -GraphRequestTimeoutSeconds $GraphRequestTimeoutSeconds -Quiet:$Quiet
                    $note = "$($graphResult.Collected) source(s) collected" + ($graphResult.Skipped.Count -gt 0 ? ", $($graphResult.Skipped.Count) skipped" : '')
                    $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'collected'; Note = $note; Skipped = $graphResult.Skipped })
                    foreach ($row in $graphResult.Skipped) { $skipped.Add($row) }
                    foreach ($row in $graphResult.Notes) { $notes.Add($row) }
                }
                else {
                    $logPath = $run ? $run.Log : $null
                    Invoke-RaidinessModuleScript -Name $module.Script -TenantId $tenant.TenantId -TenantName $tenant.TenantName -OutputPath $dataPath -HashUpns:$HashUpns -LogPath $logPath
                    $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'collected'; Note = "raidiness-$($module.Script).json" })
                    Write-RaidinessLog -Phase 'module' -Source $module.Name -Message 'collected'
                }
            }
            catch {
                # One declined sign-in or missing role must not cost the rest of the
                # run; the report degrades this module's checks to "not measured".
                Write-Warning "Skipped $($module.Title): $($_.Exception.Message)"
                Write-RaidinessLog -Level error -Phase 'module' -Source $module.Name -Message "skipped: $($_.Exception.Message)"
                $summary.Add([pscustomobject]@{ Module = $module.Name; Status = 'skipped'; Note = $_.Exception.Message })
                $skipped.Add([pscustomobject]@{ Key = $module.Name; Kind = 'module'; Reason = $_.Exception.Message })
            }
        }
    }
    finally {
        Write-RaidinessProgress -Id 1 -Quiet:$Quiet -Activity 'Raidiness checkup' -Completed
    }

    # What could not be collected travels with the evidence, so the report can
    # say why a check is "not measured" instead of leaving the reader guessing.
    # Out-Null, or the path it returns joins the summary this cmdlet emits.
    Write-RaidinessRunContext -Path $dataPath -TenantId ($tenant ? $tenant.TenantId : $null) -Skipped $skipped.ToArray() -Notes $notes.ToArray() | Out-Null

    Write-Host ''
    Write-Host 'Collection summary:' -ForegroundColor Cyan
    foreach ($row in $summary) {
        $color = $row.Status -eq 'collected' ? 'Green' : 'Yellow'
        Write-Host (" {0,-15} {1,-10} {2}" -f $row.Module, $row.Status, $row.Note) -ForegroundColor $color
    }
    if ($skipped.Count -gt 0) {
        Write-Host " $($skipped.Count) source(s) not collected — see the reasons in the run log or raidiness-run.json." -ForegroundColor Yellow
    }

    $resolvedOutput = Resolve-Path -Path $dataPath -ErrorAction SilentlyContinue
    $dataFolder = $resolvedOutput ? $resolvedOutput.Path : $dataPath
    Write-Host "Data folder: $dataFolder"
    if ($run) {
        Write-Host "Run folder: $($run.Root)"
        Write-Host "Next: Export-RaidinessReport -RunPath '$($run.Root)' (add -LLM for an LLM-ready markdown bundle)."
        Close-RaidinessLog | Out-Null
    }
    else {
        Write-Host 'Next: Export-RaidinessReport (add -LLM for an LLM-ready markdown bundle).'
    }
    Write-Host "Portal CSV exports (SAM DAG, apps inventory, MSCommerce) can be dropped into $dataFolder"

    return $summary.ToArray()
}