Public/Invoke-PckCopilotPipeline.ps1

function Invoke-PckCopilotPipeline {
    <#
    .SYNOPSIS
    The paved road: preflight, offline validation, pack, import, and publish
    against a pinned environment, in one call.

    .DESCRIPTION
    Forward from source (design 5.9): the workspace is packed and imported as
    the repository defines it; nothing is inferred from what the environment
    already contains.

    Order: environment context (canon 4), pac floor (war story 6), pac auth
    (implicit mode per design 5.2: a complete PCK_SPN_* set creates a
    temporary CI profile that is deleted on exit, anything else verifies the
    active dev profile is aligned), offline workspace lint, pac copilot pack,
    pac solution import with publish. The first failure stops the pipeline
    with the appropriate exit code; nothing later runs.

    -WhatIf runs everything read-only (context, floor, alignment, lint) and
    reports the pack and import it would have run. This is what the MCP verb
    plan-deployment wraps.

    .EXAMPLE
    Invoke-PckCopilotPipeline -SolutionName WorkbenchSupportAssistant `
        -SourcePath C:\src\agent -PublisherPrefix wrk -Json

    .EXAMPLE
    Invoke-PckCopilotPipeline -SolutionName WorkbenchSupportAssistant `
        -SourcePath .\agent -PublisherPrefix wrk -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string] $SolutionName,

        # The pac copilot workspace. Absolute, or relative to PCK_WORKSPACE_ROOT.
        [Parameter(Mandatory)]
        [string] $SourcePath,

        [Parameter(Mandatory)]
        [string] $PublisherPrefix,

        [string] $EnvironmentId,

        [switch] $Json
    )

    # These values become pac arguments and a filename; refuse anything that
    # could not be a solution name or publisher prefix before pac sees it.
    if ($SolutionName -notmatch '^[A-Za-z0-9_]+$') {
        throw [PckError]::new(
            "Solution name '$SolutionName' is not a valid solution unique name (letters, digits, underscores).",
            $script:PckExitCode.General)
    }
    if ($PublisherPrefix -notmatch '^[A-Za-z][A-Za-z0-9]{1,7}$') {
        throw [PckError]::new(
            "Publisher prefix '$PublisherPrefix' is not valid (2 to 8 alphanumerics, starting with a letter).",
            $script:PckExitCode.General)
    }

    $src = Resolve-PckOutputPath -Path $SourcePath
    if (-not (Test-Path -LiteralPath $src -PathType Container)) {
        throw [PckError]::new("Source path '$src' does not exist.", $script:PckExitCode.General)
    }

    $steps = [System.Collections.Generic.List[object]]::new()

    # 1. Environment context (canon 4). Reuse the session connection only when
    # it matches the pinned environment.
    $resolvedEnv = Resolve-PckEnvironmentId -EnvironmentId $EnvironmentId
    if (-not $script:PckContext -or $script:PckContext.EnvironmentId -ne $resolvedEnv) {
        Connect-PckPowerPlatform -EnvironmentId $resolvedEnv | Out-Null
    }
    $steps.Add([pscustomobject]@{ Step = 'connect'; Status = 'ok'; Detail = $script:PckContext.EnvironmentUrl })

    # 2. pac floor (war story 6).
    Assert-PckPacVersion
    $steps.Add([pscustomobject]@{ Step = 'pac-version'; Status = 'ok'; Detail = 'at or above the tested floor' })

    $ciProfile = $null
    $zip = $null
    try {
        # 3. pac auth, mode implicit from the PCK_SPN_* set (design 5.2).
        $spnComplete = -not [string]::IsNullOrWhiteSpace($env:PCK_SPN_TENANT) -and
                       -not [string]::IsNullOrWhiteSpace($env:PCK_SPN_APP_ID) -and
                       -not [string]::IsNullOrWhiteSpace($env:PCK_SPN_SECRET)
        if ($spnComplete) {
            $profileName = "pck-ci-$PID"
            if ($PSCmdlet.ShouldProcess("pac auth profile '$profileName'", 'Create temporary CI auth profile')) {
                Invoke-PckPacCommand -Sensitive -Arguments @(
                    'auth', 'create', '--name', $profileName,
                    '--applicationId', $env:PCK_SPN_APP_ID,
                    '--clientSecret', $env:PCK_SPN_SECRET,
                    '--tenant', $env:PCK_SPN_TENANT,
                    '--environment', $resolvedEnv) | Out-Null
                $ciProfile = $profileName
                $steps.Add([pscustomobject]@{ Step = 'pac-auth'; Status = 'ok'; Detail = 'temporary CI profile created' })
            }
            else {
                $steps.Add([pscustomobject]@{ Step = 'pac-auth'; Status = 'whatif'; Detail = 'would create a temporary CI profile' })
            }
        }
        else {
            Assert-PckProfileAligned -EnvironmentId $resolvedEnv -EnvironmentUrl $script:PckContext.EnvironmentUrl
            $steps.Add([pscustomobject]@{ Step = 'pac-auth'; Status = 'ok'; Detail = 'active dev profile aligned' })
        }

        # 4. Offline lint, before anything touches the tenant (design 6.2).
        $issues = @(Test-PckAgentWorkspace -Path $src)
        if ($issues.Count -gt 0) {
            $detail = ($issues | ForEach-Object { "$($_.File):$($_.Line) [$($_.Rule)] $($_.Message)" }) -join "`n"
            throw [PckError]::new(
                "Workspace validation failed with $($issues.Count) issue(s). Nothing was deployed.`n$detail",
                $script:PckExitCode.General)
        }
        $steps.Add([pscustomobject]@{ Step = 'validate'; Status = 'ok'; Detail = 'workspace lint clean' })

        # 5. Pack. Local, no auth, safe in a build pipeline; the zip lands in
        # the workspace.
        if ($PSCmdlet.ShouldProcess($src, "pac copilot pack ($SolutionName)")) {
            Invoke-PckPacCommand -WorkingDirectory $src -Arguments @(
                'copilot', 'pack',
                '--publisher-prefix', $PublisherPrefix,
                '--solution-name', $SolutionName) | Out-Null

            $zip = Join-Path $src "$SolutionName.zip"
            if (-not (Test-Path -LiteralPath $zip)) {
                $newest = Get-ChildItem -LiteralPath $src -Filter '*.zip' -File |
                    Sort-Object LastWriteTime -Descending | Select-Object -First 1
                if (-not $newest) {
                    throw [PckError]::new(
                        'pac copilot pack completed but produced no zip in the source path.',
                        $script:PckExitCode.General)
                }
                $zip = $newest.FullName
            }
            $steps.Add([pscustomobject]@{ Step = 'pack'; Status = 'ok'; Detail = $zip })
        }
        else {
            $steps.Add([pscustomobject]@{ Step = 'pack'; Status = 'whatif'; Detail = "would pack $src as $SolutionName" })
        }

        # 6. Import and publish, against the profile just verified or created.
        if ($zip -and $PSCmdlet.ShouldProcess($script:PckContext.EnvironmentUrl, "pac solution import ($SolutionName, publish changes)")) {
            Invoke-PckPacCommand -WorkingDirectory $src -Arguments @(
                'solution', 'import', '--path', $zip, '--publish-changes') | Out-Null
            $steps.Add([pscustomobject]@{ Step = 'import'; Status = 'ok'; Detail = 'imported and published' })
        }
        elseif (-not $zip) {
            $steps.Add([pscustomobject]@{ Step = 'import'; Status = 'whatif'; Detail = "would import into $($script:PckContext.EnvironmentUrl)" })
        }
    }
    finally {
        # The temporary CI profile never outlives the run (design 5.2).
        if ($ciProfile) {
            try {
                Invoke-PckPacCommand -Arguments @('auth', 'delete', '--name', $ciProfile) | Out-Null
            }
            catch {
                Write-Warning "Could not delete temporary CI auth profile '$ciProfile': $($_.Exception.Message)"
            }
        }
    }

    $result = [pscustomobject]@{
        SolutionName  = $SolutionName
        EnvironmentId = $resolvedEnv
        SourcePath    = $src
        ZipPath       = $zip
        Steps         = @($steps)
        Succeeded     = $true
    }
    if ($Json) { ConvertTo-Json -InputObject $result -Depth 5 } else { $result }
}