src/Connect-CciGet.ps1

function Connect-CciGet {
<#
.SYNOPSIS
    Prepare access to CCI module sources for this session.
.DESCRIPTION
    A tenant publishes the same modules to two places:
 
      * the Azure Artifacts FEED - canonical publish target, and the natural
        source for developers and CI. Reading it requires an Azure DevOps org
        entitlement and a paid Basic licence (Stakeholder has no Artifacts
        access).
      * the tenant DISTRIBUTION STORE - an Entra/Azure-RBAC gated mirror in the
        tenant's storage account. Machine builders already hold RBAC there for
        customization payloads and need no Azure DevOps identity at all.
 
    Connect-CciGet picks ONE source and signs in once:
 
      Auto (default):
        1. Live Azure CLI session -> FEED, seeded from that session (no prompt).
        2. Otherwise -> STORE (one device code). This is the machine-builder
           path: no Azure DevOps entitlement required, and no credential
           provider download.
        3. Store unavailable/unconfigured -> FEED via an interactive
           device-code sign-in.
      Feed / Store: force that source.
 
    Earlier versions always tried the feed first and fell back to the store on
    failure, which made a machine builder complete TWO device codes - one for a
    resource they cannot use. Choosing up front avoids that.
.PARAMETER Tenant
    Optional. Limit to the named tenant.
.PARAMETER Source
    Auto (default), Feed, or Store.
.PARAMETER SkipCredentialProvider
    Skip the Azure Artifacts credential-provider bootstrap. Implied when the
    resolved source is the store.
#>

    [CmdletBinding()]
    param(
        [string]$Tenant,
        [ValidateSet('Auto','Feed','Store')]
        [string]$Source = 'Auto',
        [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."
        }
    }

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

    # --- decide the source BEFORE prompting for anything -------------------
    $azdoToken = $null
    if ($Source -ne 'Store' -and (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 { }
    }

    $storeConfigured = @($feeds | Where-Object {
        $p = $_.PSObject.Properties['blobAccount']; $p -and $p.Value
    }).Count -gt 0

    $useStore = switch ($Source) {
        'Store' { $true }
        'Feed'  { $false }
        default { (-not $azdoToken) -and $storeConfigured }
    }

    # --- store path: one Entra sign-in, no Azure DevOps identity needed -----
    if ($useStore) {
        $connected = $false
        foreach ($feed in $feeds) {
            $blobCtx = _Connect-CciBlobStore -Feed $feed
            if (-not $blobCtx) { continue }
            $index = _Get-CciBlobModuleIndex -Feed $feed -Context $blobCtx
            if (-not $index) {
                Write-Warning "cciget: the '$($feed.name)' distribution store has no module index yet."
                continue
            }
            $names = @($index.modules.PSObject.Properties.Name)
            Write-Host "cciget: using the '$($feed.name)' tenant distribution store ($($names.Count) modules available)."
            $connected = $true
        }
        if ($connected) {
            $Script:CciGetSource = 'store'
            $Script:CciGetModuleCache = $null
            _Write-CciGetNextStep
            return [pscustomobject]@{ Source = 'store'; Tenant = ($feeds | ForEach-Object { $_.name }) -join ',' }
        }
        if ($Source -eq 'Store') {
            throw "cciget: the tenant distribution store is not reachable and -Source Store was requested."
        }
        Write-Warning "cciget: distribution store unavailable; falling back to the Azure Artifacts feed."
    }

    # --- feed path ---------------------------------------------------------
    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."
            }
        }
    }

    # 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 (-not $azdoToken) {
        # No Azure CLI: PSResourceGet runs the provider in PLUGIN mode, which
        # cannot prompt (it fails with exit code 2). Drive it directly.
        $azdoToken = _Invoke-CciCredentialProvider -FeedUrl $feeds[0].url
    }
    if ($azdoToken) {
        $endpointJson = @{ endpointCredentials = @($feeds | ForEach-Object {
            @{ endpoint = $_.url; username = 'VssSessionToken'; password = $azdoToken }
        }) } | 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."
    }

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

        if ($feed.tenantId) {
            $env:ARTIFACTS_CREDENTIALPROVIDER_MSAL_AUTHORITY = "https://login.microsoftonline.com/$($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)"
        }

        Write-Host "cciget: authenticating to '$repoName'..."
        $probeOk = $false
        try {
            # Wildcard probe: hits the V2 search endpoint, which genuinely
            # requires auth (specific-name lookups can return empty 200s).
            $null = Find-PSResource -Name '*' -Repository $repoName -ErrorAction Stop
            $probeOk = $true
        } catch {
            $msg = $_.Exception.Message
            if ($msg -match 'No match was found|could not be found in repository') {
                $probeOk = $true      # authenticated; feed is simply empty
            } 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"
                $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 ', ')"
        if ($storeConfigured -and $Source -eq 'Auto') {
            Write-Warning "cciget: retry with -Source Store to use the tenant distribution store instead (no Azure DevOps entitlement required)."
        }
        $Script:CciGetSource = $null
    }
    else {
        $Script:CciGetSource = 'feed'
    }

    Get-PSResourceRepository -Name (_Get-CciGetRepositoryName -FeedName '*') |
        Select-Object Name, Uri, Trusted
}