Modules/businessdev.ALbuild.Feeds/Public/Resolve-BcExternalDependency.ps1

function Resolve-BcExternalDependency {
    <#
    .SYNOPSIS
        Stages dependency apps that are not resolved from a NuGet feed (Azure DevOps Universal feed
        packages and committed local .app files) into a folder, and returns their identities.
 
    .DESCRIPTION
        Complements the NuGet dependency resolver for sources it cannot query by app id:
          * UniversalPackages - named packages downloaded from an Azure DevOps Universal feed via
            Get-BcUniversalPackage (e.g. your own apps published to a 'Products' feed).
          * LocalPackage - repository folders of committed .app files, for ISVs without any public
            feed (e.g. CKL). Relative folders are resolved against -BaseFolder.
 
        All staged .app files land in -OutputFolder; the returned identities (read with
        Expand-BcAppFile) let the caller pin them as a satisfied baseline for NuGet resolution and
        install them into the container alongside the feed-resolved apps. Missing local folders are
        skipped with a warning; symbol-only files are kept (the caller decides installability).
 
    .PARAMETER UniversalPackages
        Universal feed entries, each an object/hashtable: { feed, name, version?, organization?,
        project? }. 'organization' falls back to -Organization; 'version' defaults to '*'.
 
    .PARAMETER LocalPackage
        Folders of committed .app files (relative paths resolved against -BaseFolder).
 
    .PARAMETER OutputFolder
        Folder the packages are staged into (created if missing).
 
    .PARAMETER Organization
        Default Azure DevOps organization (URL or name) for Universal entries without their own.
 
    .PARAMETER Project
        Default project for project-scoped Universal feeds.
 
    .PARAMETER AccessToken
        PAT for the Universal download (exported as AZURE_DEVOPS_EXT_PAT by Get-BcUniversalPackage).
 
    .PARAMETER BaseFolder
        Root used to resolve relative -LocalPackage folders. Default: current location.
 
    .OUTPUTS
        PSCustomObject per staged app: Id, Name, Publisher, Version, File.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Resolves the full set of external dependency packages; the plural is intentional.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [object[]] $UniversalPackages = @(),
        [string[]] $LocalPackage = @(),
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder,
        [string] $Organization,
        [string] $Project,
        [string] $AccessToken,
        [string] $BaseFolder = (Get-Location).Path
    )

    if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }

    $field = {
        param($source, [string] $name)
        if ($null -eq $source) { return $null }
        $prop = $source.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
        if ($prop) { return $prop.Value }
        return $null
    }

    foreach ($entry in @($UniversalPackages)) {
        $feed = & $field $entry 'feed'
        $name = & $field $entry 'name'
        if (-not $feed -or -not $name) {
            Write-ALbuildLog -Level Warning "Skipping a universalPackages entry without 'feed'/'name'."
            continue
        }
        $org = & $field $entry 'organization'; if (-not $org) { $org = $Organization }
        if (-not $org) { throw "Universal package '$name' has no 'organization' and no default was provided." }
        $proj = & $field $entry 'project'; if (-not $proj) { $proj = $Project }
        $version = & $field $entry 'version'; if (-not $version) { $version = '*' }

        $packageArgs = @{ Organization = $org; Feed = $feed; Name = $name; Version = $version; OutputFolder = $OutputFolder }
        if ($proj) { $packageArgs['Project'] = $proj }
        if ($AccessToken) { $packageArgs['AccessToken'] = $AccessToken }
        # Non-fatal: if a universal package is missing or access is denied, fall through to the local
        # package(s) instead of failing the resolve. Local packages are the availability fallback.
        try {
            Get-BcUniversalPackage @packageArgs | Out-Null
        }
        catch {
            Write-ALbuildLog -Level Warning "Universal package '$name' (feed '$feed') unavailable: $($_.Exception.Message). Falling back to local/other sources."
        }
    }

    foreach ($folder in @($LocalPackage)) {
        if ([string]::IsNullOrWhiteSpace($folder)) { continue }
        # [IO.Path]::Combine / [IO.Directory]::Exists (not Join-Path / Test-Path) so a drive-qualified
        # path on a foreign OS - e.g. a Windows 'C:\...' folder evaluated on Linux - is combined and
        # reported missing instead of throwing a terminating DriveNotFoundException (both Join-Path and
        # Test-Path resolve the drive qualifier) under $ErrorActionPreference = 'Stop'.
        $resolved = if ([System.IO.Path]::IsPathRooted($folder)) { $folder } else { [System.IO.Path]::Combine($BaseFolder, $folder) }
        if (-not [System.IO.Directory]::Exists($resolved)) {
            Write-ALbuildLog "Local dependency folder '$resolved' not found; skipping."
            continue
        }
        $apps = @(Get-ChildItem -LiteralPath $resolved -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)
        foreach ($app in $apps) { Copy-Item -LiteralPath $app.FullName -Destination $OutputFolder -Force }
        Write-ALbuildLog "Staged $($apps.Count) local dependency package(s) from '$resolved'."
    }

    # Read every staged package, then keep only the newest version per app id - several sources may
    # serve the same app (e.g. universal + local). Files for superseded versions are removed from the
    # staging folder so only the chosen one is pinned and installed.
    $byId = @{}
    $loose = [System.Collections.Generic.List[object]]::new()   # packages without a readable id
    foreach ($file in (Get-ChildItem -LiteralPath $OutputFolder -Filter '*.app' -File -ErrorAction SilentlyContinue)) {
        try { $info = Expand-BcAppFile -Path $file.FullName }
        catch { Write-ALbuildLog -Level Warning "Could not read staged package '$($file.Name)': $($_.Exception.Message)"; continue }
        finally { if ($info -and $info.ExtractedPath -and (Test-Path -LiteralPath $info.ExtractedPath)) { Remove-Item -LiteralPath $info.ExtractedPath -Recurse -Force -ErrorAction SilentlyContinue } }

        $entry = [PSCustomObject]@{ Id = $info.Id; Name = $info.Name; Publisher = $info.Publisher; Version = $info.Version; File = $file.FullName }
        if (-not $info.Id) { $loose.Add($entry); continue }

        $id = "$($info.Id)".ToLowerInvariant()
        if (-not $byId.ContainsKey($id)) { $byId[$id] = $entry; continue }

        $kept = $byId[$id]
        if ((ConvertTo-BcVersion $entry.Version) -gt (ConvertTo-BcVersion $kept.Version)) {
            Write-ALbuildLog "Superseding '$($kept.Name)' $($kept.Version) with newer $($entry.Version) from another source."
            Remove-Item -LiteralPath $kept.File -Force -ErrorAction SilentlyContinue
            $byId[$id] = $entry
        }
        else {
            if ($entry.File -ne $kept.File) { Remove-Item -LiteralPath $entry.File -Force -ErrorAction SilentlyContinue }
        }
    }
    return @(@($byId.Values) + @($loose))
}