src/Connect-CciGet.ps1

function Connect-CciGet {
<#
.SYNOPSIS
    Register configured CCI feeds as PSResource repositories and prepare credentials.
.DESCRIPTION
    For each enabled feed in Get-CciGetConfig, Connect-CciGet:
      - Ensures the Azure Artifacts Credential Provider is installed (one-time
        bootstrap on Windows; uses the Microsoft installer at
        https://github.com/microsoft/artifacts-credprovider).
      - Calls Register-PSResourceRepository for the feed (idempotent).
    After Connect-CciGet succeeds, Find-CciModule / Install-CciModule will
    transparently use the user's Entra identity (or, in CI, a workload identity)
    to authenticate to the feed. No PATs are required.
.PARAMETER Tenant
    Optional. Limit registration to the named feed only.
.PARAMETER SkipCredentialProvider
    Skip the credential-provider bootstrap (use when you have managed it
    centrally or are running in CI with a different auth strategy).
#>

    [CmdletBinding()]
    param(
        [string]$Tenant,
        [switch]$SkipCredentialProvider
    )

    if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet)) {
        throw "cciget: Microsoft.PowerShell.PSResourceGet is required but not installed. Run: Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser"
    }
    Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop

    # Remote registry refresh (designed-in hook, unbound by default): when the
    # config carries a registryUrl, fetch a replacement registry and persist it.
    # The baked registry remains the fallback on any failure.
    $cfg = Get-CciGetConfig
    $registryUrlProp = $cfg.PSObject.Properties['registryUrl']
    $registryUrl = if ($registryUrlProp) { $registryUrlProp.Value }
    if ($registryUrl) {
        try {
            $remote = Invoke-RestMethod -Uri $registryUrl -TimeoutSec 15
            if ($remote.feeds -and $remote.defaultFeed) {
                $path = _Get-CciGetConfigPath
                $dir = Split-Path $path -Parent
                if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
                [System.IO.File]::WriteAllText($path, ($remote | ConvertTo-Json -Depth 6), (New-Object System.Text.UTF8Encoding $false))
                Write-Host "cciget: tenant registry refreshed from $registryUrl."
            } else {
                Write-Warning "cciget: registry at $registryUrl is malformed (needs feeds + defaultFeed); using local config."
            }
        } catch {
            Write-Warning "cciget: registry refresh from $registryUrl failed ($_); using local config."
        }
    }

    if (-not $SkipCredentialProvider) {
        $pluginRoot = Join-Path $env:USERPROFILE '.nuget\plugins\netcore'
        if (-not (Test-Path $pluginRoot) -or -not (Get-ChildItem $pluginRoot -Filter 'CredentialProvider.Microsoft.dll' -Recurse -ErrorAction SilentlyContinue)) {
            Write-Host "cciget: installing Azure Artifacts Credential Provider..."
            try {
                $script = (New-Object System.Net.WebClient).DownloadString('https://aka.ms/install-artifacts-credprovider.ps1')
                Invoke-Expression $script
            } catch {
                Write-Warning "cciget: credential provider install failed: $_. You may need to install manually from https://github.com/microsoft/artifacts-credprovider."
            }
        }
    }

    $feeds = _Resolve-CciGetFeed -Tenant $Tenant
    if (-not $Tenant -and @($feeds).Count -eq 1) {
        Write-Host "cciget: single configured tenant '$($feeds[0].name)' auto-selected."
    }
    $authFailed = @()

    # Credential provider configuration for this session. These persist after
    # Connect-CciGet returns so Find-/Install-CciModule benefit too.
    #
    # FORCE_CANSHOWDIALOG_TO=false: PSResourceGet invokes the provider in plugin
    # mode with CanShowDialog=true, which attempts the WAM broker dialog and
    # fails on freshly built machines with 0x800703f0 (ERROR_NO_TOKEN) instead
    # of falling back. Forcing it off selects the device-code flow, which works.
    # DEVICEFLOWTIMEOUTSECONDS: the provider default is 90s - not enough time for
    # a human to open a browser and complete the sign-in, producing
    # "A task was canceled". Give it 10 minutes.
    # Both preferred (ARTIFACTS_*) and legacy (NUGET_*/VSS_*) names are set;
    # older provider builds only understand the legacy ones.
    $env:ARTIFACTS_CREDENTIALPROVIDER_FORCE_CANSHOWDIALOG_TO = 'false'
    $env:NUGET_CREDENTIALPROVIDER_FORCE_CANSHOWDIALOG_TO     = 'false'
    $env:ARTIFACTS_CREDENTIALPROVIDER_DEVICEFLOWTIMEOUTSECONDS   = '600'
    $env:NUGET_CREDENTIALPROVIDER_VSTS_DEVICEFLOWTIMEOUTSECONDS = '600'

    # If an Azure CLI session is active, pre-seed feed credentials so the
    # credential provider doesn't need to prompt interactively. This allows
    # non-interactive scenarios (remoting, CI, WAM-incompatible shells).
    $azdoToken = $null
    if (Get-Command az -ErrorAction SilentlyContinue) {
        try {
            $azdoToken = az account get-access-token --resource '499b84ac-1321-427f-aa17-267ca6975798' --query accessToken -o tsv 2>$null
        } catch { }
    }
    if ($azdoToken) {
        $endpoints = @{ endpointCredentials = @($feeds | ForEach-Object {
            @{ endpoint = $_.url; username = 'VssSessionToken'; password = $azdoToken }
        }) }
        $endpointJson = $endpoints | ConvertTo-Json -Compress -Depth 3
        $env:VSS_NUGET_EXTERNAL_FEED_ENDPOINTS = $endpointJson
        $env:ARTIFACTS_CREDENTIALPROVIDER_EXTERNAL_FEED_ENDPOINTS = $endpointJson
        Write-Verbose "cciget: seeded feed credentials from Azure CLI session."
    }
    else {
        # Fresh-machine path: no Azure CLI. PSResourceGet runs the credential
        # provider in PLUGIN mode, which cannot prompt — it fails with exit
        # code 2 rather than starting a device-code flow. Drive the provider
        # directly (stand-alone, interactive) and seed the same endpoint
        # variable, so the probe below behaves identically to the az path.
        $interactiveToken = _Invoke-CciCredentialProvider -FeedUrl $feeds[0].url
        if ($interactiveToken) {
            $endpoints = @{ endpointCredentials = @($feeds | ForEach-Object {
                @{ endpoint = $_.url; username = 'VssSessionToken'; password = $interactiveToken }
            }) }
            $endpointJson = $endpoints | ConvertTo-Json -Compress -Depth 3
            $env:VSS_NUGET_EXTERNAL_FEED_ENDPOINTS = $endpointJson
            $env:ARTIFACTS_CREDENTIALPROVIDER_EXTERNAL_FEED_ENDPOINTS = $endpointJson
            Write-Verbose "cciget: seeded feed credentials from interactive sign-in."
        }
    }

    foreach ($feed in $feeds) {
        $repoName = _Get-CciGetRepositoryName -FeedName $feed.name

        # Set tenant-specific authority for this feed.
        if ($feed.tenantId) {
            $env:NUGET_CREDENTIALPROVIDER_MSAL_AUTHORITY = "https://login.microsoftonline.com/$($feed.tenantId)"
        }

        $existing = Get-PSResourceRepository -Name $repoName -ErrorAction SilentlyContinue
        if ($existing) {
            if ($existing.Uri -ne $feed.url) {
                Set-PSResourceRepository -Name $repoName -Uri $feed.url -Trusted
                Write-Verbose "cciget: updated $repoName URL."
            }
        } else {
            Register-PSResourceRepository -Name $repoName -Uri $feed.url -Trusted
            Write-Host "cciget: registered repository '$repoName' -> $($feed.url)"
        }

        # Verify auth by probing the feed. This triggers the credential provider
        # now (at connect time) so auth issues surface here, not in Find-CciModule.
        Write-Host "cciget: authenticating to '$repoName'..."
        $probeOk = $false
        try {
            # Use a wildcard probe — this hits the V2 search endpoint which
            # actually requires authentication, unlike specific-name lookups
            # which may return empty 200s without auth.
            $null = Find-PSResource -Name '*' -Repository $repoName -ErrorAction Stop
            # Got results — auth works and the feed has packages.
            $probeOk = $true
        } catch {
            $msg = $_.Exception.Message
            if ($msg -match 'No match was found|could not be found in repository') {
                # Auth succeeded — feed is simply empty.
                $probeOk = $true
            } elseif ($msg -match '\[Warning\].*CredentialProvider' -and $msg -notmatch '401|Unauthorized|forbidden') {
                Write-Verbose "cciget: credential provider warning (non-fatal): $msg"
                $probeOk = $true
            } else {
                Write-Warning "cciget: authentication failed for '$repoName'."
                Write-Warning " Error: $msg"
                # NOTE: single-quoted + concatenated deliberately. A previous
                # version used C-style \" escaping inside a double-quoted string,
                # which terminates the PowerShell string early and made
                # Connect-CciGet fail with PositionalParameterNotFound.
                $recovery = ' & "$env:USERPROFILE\.nuget\plugins\netcore\CredentialProvider.Microsoft\CredentialProvider.Microsoft.exe" -U ' + $feed.url + ' -C false -I -R'
                Write-Warning ' Hint: run the credential provider directly to sign in, then retry Connect-CciGet:'
                Write-Warning $recovery
                $authFailed += $repoName
            }
        }

        if ($probeOk) {
            Write-Host "cciget: '$repoName' authenticated and ready."
        }
    }

    if ($authFailed.Count -gt 0) {
        Write-Warning "cciget: $($authFailed.Count) feed(s) failed authentication: $($authFailed -join ', ')"
        Write-Warning "cciget: You may need to run Connect-CciGet again. If prompted for a device code, complete the sign-in in your browser."
    }

    # Return a clean summary instead of the wide default table.
    Get-PSResourceRepository -Name (_Get-CciGetRepositoryName -FeedName '*') |
        Select-Object Name, Uri, Trusted
}