AL/Get-ALDependencies.ps1

function Get-ALDependencies {
<#
.SYNOPSIS
    Downloads all AL dependencies for a Business Central project into .alpackages.
.DESCRIPTION
    Reads app.json from the project at SourcePath, resolves every non-Microsoft, non-test
    dependency recursively, and copies the .app files into .alpackages. Dependencies are
    downloaded from the last successful Azure DevOps build for each project.

    Each dependency is only downloaded once per invocation (deduplication cache). All
    temporary directories created during the run are cleaned up automatically.
.PARAMETER SourcePath
    Root folder of the AL project (must contain app.json). Defaults to the current directory.
.PARAMETER ContainerName
    Name of the BC Docker container used to check which apps are already installed.
    Defaults to the container defined in .vscode/launch.json.
.PARAMETER Install
    Publish and install each downloaded app into the BC container.
.PARAMETER WriteDepenciesToCSVFile
    Append each resolved dependency to .alpackages\dep.csv.
.PARAMETER Inspect
    Dry-run mode. Resolves and validates the full dependency graph (verifying that a
    successful build exists for every dependency) without downloading, copying, or
    touching the container.
.EXAMPLE
    Get-ALDependencies
.EXAMPLE
    Get-ALDependencies -Install
.EXAMPLE
    Get-ALDependencies -Inspect
#>

    Param(
        [Parameter(Mandatory=$false)]
        [string]$SourcePath = (Get-Location),
        [Parameter(Mandatory=$false)]
        [string]$ContainerName = (Get-ContainerFromLaunchJson),
        [Parameter(Mandatory=$false)]
        [switch]$Install,
        [Parameter(Mandatory=$false)]
        [switch]$WriteDepenciesToCSVFile,
        [Parameter(Mandatory=$false)]
        [switch]$Inspect
    )

    $RepositoryName = (Get-EnvironmentKeyValue -SourcePath $SourcePath -KeyName 'repo')

    if ($null -eq $RepositoryName) {

        if(($SourcePath -eq (Get-Location)) -and (Get-IsGitRepo ($SourcePath)))
        {
            $RepositoryUrl = Get-GitRepoFetchUrl
            if (($RepositoryUrl.EndsWith('-AL')) -or ($RepositoryUrl.EndsWith('-BC'))) {
                $RepositoryName = $RepositoryUrl.Substring($RepositoryUrl.Length - 3)
            }
        }
    }

    if ($null -ne $RepositoryName)    {
        Write-Host "Repository Name: $RepositoryName."
    }

    $script:ALDependencyCache = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $script:ALTempDirectories = [System.Collections.Generic.List[string]]::new()
    $script:ALLastExtractPath = $null

    if (-not $Inspect.IsPresent) {
        if (!([IO.Directory]::Exists((Join-Path $SourcePath '.alpackages')))) {
            New-EmptyDirectory (Join-Path $SourcePath '.alpackages')
        }
    }

    $AppJson = Get-Content (Join-Path $SourcePath 'app.json') -Raw -Encoding UTF8 | ConvertFrom-Json

    try {
        Get-ALDependenciesFromAppJson -AppJson $AppJson -SourcePath $SourcePath -SavePath $SourcePath -RepositoryName $RepositoryName -ContainerName $ContainerName -Install:$Install -WriteDepenciesToCSVFile:$WriteDepenciesToCSVFile -Inspect:$Inspect
    } finally {
        foreach ($dir in $script:ALTempDirectories) {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        }
    }
}

function Get-ALDependenciesFromAppJson {
<#
.SYNOPSIS
    Recursively resolves and downloads AL dependencies described in an app.json object.
.DESCRIPTION
    For each non-Microsoft, non-test dependency in AppJson:
      1. Looks up project/repo/version from environment.json (if present) or derives them
         from the dependency name and RepositoryName.
      2. Skips dependencies already installed in the container or already processed this run.
      3. Downloads the artifact from the last successful Azure DevOps build. app.json and
         environment.json are read from the artifact if available, otherwise fetched from
         the repository default branch via the API.
      4. Recurses into transitive dependencies before copying .app files to SavePath\.alpackages.
.PARAMETER AppJson
    Parsed app.json object whose dependencies are to be resolved.
.PARAMETER SourcePath
    Folder containing environment.json for the current app. Used to look up per-project
    dependency overrides.
.PARAMETER SavePath
    Root folder where .alpackages lives. Defaults to the current directory.
.PARAMETER RepositoryName
    Azure DevOps repository name used as the default when a dependency has no explicit repo.
.PARAMETER ContainerName
    BC Docker container used to check which apps are already installed.
.PARAMETER Install
    Publish and install each downloaded app into the container.
.PARAMETER WriteDepenciesToCSVFile
    Append each resolved dependency to SavePath\.alpackages\dep.csv.
.PARAMETER Inspect
    Dry-run mode — validates dependency availability without downloading or installing.
#>

    Param(
        [Parameter(Mandatory=$true)]
        $AppJson,
        [Parameter(Mandatory=$false)]
        [string]$SourcePath = (Get-Location),
        [Parameter(Mandatory=$false)]
        [string]$SavePath = (Get-Location),
        [Parameter(Mandatory=$false)]
        [string]$RepositoryName,
        [Parameter(Mandatory=$false)]
        [string]$ContainerName,
        [Parameter(Mandatory=$false)]
        [switch]$Install,
        [Parameter(Mandatory=$false)]
        [switch]$WriteDepenciesToCSVFile,
        [Parameter(Mandatory=$false)]
        [switch]$Inspect
    )

    if ($WriteDepenciesToCSVFile -and -not $Inspect.IsPresent) {
        $dependencyFile = Join-Path (Join-Path $SavePath '.alpackages') dep.csv
        if (Test-Path $dependencyFile)  {
            Remove-Item $dependencyFile -Force
        }
    }

    if ($RepositoryName -eq '') {
        $RepositoryName = 'BC'
    }

    foreach ($Dependency in $AppJson.dependencies | Where-Object Name -NotLike '*Test*') {
        if ($null -ne $Dependency) {
            # is the source for this app defined in the environment file?
            $EnvDependency = Get-DependencyFromEnvironment -SourcePath $SourcePath -Name $Dependency.name
            if ($null -ne $EnvDependency) {
                if ($null -ne $EnvDependency.includetest) {
                    $IncludeTest = $EnvDependency.includetest
                }

                $DependencyProject = $EnvDependency.project
                $DependencyRepo = $EnvDependency.repo
                $DependencyVersion = $EnvDependency.version

            }
            # otherwise aquire the app from the last successful build
            else {
                if ($Dependency.publisher -eq 'Microsoft') {
                    $Apps = @()
                    $DependencyAppJson = ConvertFrom-Json '{}'

                    $DependencyProject = ''
                    $DependencyRepo = ''
                }
                else {
                    $DependencyProject = $Dependency.name
                    $DependencyRepo = $RepositoryName
                }
            }
            
            $InstalledDependency = Get-BCContainerAppInfo -ContainerName $ContainerName -InstalledOnly -UseNewFormat | Where-Object {$_.Name -eq $Dependency.Name} 
            if ($InstalledDependency -ne $null){
                Write-Host "Skipping installed dependency: $($InstalledDependency.Name)" -ForegroundColor Yellow
                continue                
            }            
            

            if (-not $script:ALDependencyCache.Add($Dependency.name)) {
                Write-Host "Skipping already downloaded dependency: $($Dependency.name)" -ForegroundColor Yellow
                continue
            }
            if ($DependencyProject -ne '') {
                $appNamePrefix = if ($AppJson.Name) { "$($AppJson.Name) " } else { "" }
                if ($null -ne $DependencyVersion) {
                    Write-Host "Getting ${appNamePrefix}dependency: $($Dependency.name) version: $($DependencyVersion)" -NoNewline
                }
                else {
                    Write-Host "Getting ${appNamePrefix}dependency: $($Dependency.name)" -NoNewline
                }

                $Apps = Get-AppFromLastSuccessfulBuild -ProjectName $DependencyProject -RepositoryName $DependencyRepo -BuildNumber $DependencyVersion -Inspect:$Inspect
                if ($null -eq $Apps) {
                    throw "$($Dependency.name) could not be downloaded"
                }
                $appJsonInArtifact = $null
                if ($null -ne $script:ALLastExtractPath) {
                    $appJsonInArtifact = Get-ChildItem -Path $script:ALLastExtractPath -Filter 'app.json' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
                }
                if ($null -ne $appJsonInArtifact) {
                    $DependencyAppJson = Get-Content $appJsonInArtifact.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
                } else {
                    $DependencyAppJson = Get-AppJsonForProjectAndRepo -ProjectName $DependencyProject -RepositoryName $DependencyRepo
                }
            }

            # fetch any dependencies for this app
            if ($DependencyAppJson.dependencies.length -gt 0) {
                $envJsonInArtifact = $null
                if ($null -ne $script:ALLastExtractPath) {
                    $envJsonInArtifact = Get-ChildItem -Path $script:ALLastExtractPath -Filter 'environment.json' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
                }
                if ($null -ne $envJsonInArtifact) {
                    $DepSourcePath = Split-Path $envJsonInArtifact.FullName -Parent
                } else {
                    $DepSourcePath = Get-EnvironmentJsonForProjectAndRepo -ProjectName $EnvDependency.project -RepositoryName $EnvDependency.repo
                }
                Get-ALDependenciesFromAppJson -AppJson $DependencyAppJson -SourcePath $DepSourcePath -SavePath $SavePath -RepositoryName $RepositoryName -ContainerName $ContainerName -Install:$Install -WriteDepenciesToCSVFile:$WriteDepenciesToCSVFile -Inspect:$Inspect
            } else {
                Get-ALDependenciesFromAppJson -AppJson $DependencyAppJson -SourcePath $SourcePath -SavePath $SavePath -RepositoryName $RepositoryName -ContainerName $ContainerName -Install:$Install -WriteDepenciesToCSVFile:$WriteDepenciesToCSVFile -Inspect:$Inspect
            }
            
            # copy (and optionally install) the apps that have been collected
            foreach ($App in $Apps | Where-Object Name -NotLike '*Test*') {
                if ($Inspect.IsPresent) {
                    Write-Host "INSPECT: Would copy $($App.Name) to .alpackages" -ForegroundColor Cyan
                } else {
                    Copy-Item $App.FullName (Join-Path (Join-Path $SavePath '.alpackages') $App.Name)
                    if ($Install.IsPresent) {
                        try {
                            Publish-BcContainerApp -containerName $ContainerName -appFile $App.FullName -sync -install -skipVerification -checkAlreadyInstalled -IgnoreIfAppExists
                        }
                        catch {
                            if (!($_.Exception.Message.Contains('already published'))) {
                                throw $_.Exception.Message
                            }
                        }
                    }
                }
                Write-Host $App.Name
                if ($WriteDepenciesToCSVFile.IsPresent -and -not $Inspect.IsPresent) {
                    try {
                        Write-Host "Writing dependency: $($App.name) to file"
                        New-Object -TypeName PSCustomObject -Property @{ID=$App.Name } | Export-Csv -Path $dependencyFile -NoTypeInformation -Append
                    }
                    catch {
                        if (!($_.Exception.Message.Contains('error writing to csv file'))) {
                            throw $_.Exception.Message
                        }
                    }
                }
            }
            
            # optionally install the test apps that have been collected as well
            if ($IncludeTest) {
                foreach ($App in $Apps | Where-Object Name -Like '*Test*') {
                    if ($Inspect.IsPresent) {
                        Write-Host "INSPECT: Would copy $($App.Name) to .alpackages" -ForegroundColor Cyan
                    } else {
                        Copy-Item $App.FullName (Join-Path (Join-Path $SavePath '.alpackages') $App.Name)
                        if ($Install.IsPresent) {
                            try {
                                Publish-BcContainerApp -containerName $ContainerName -appFile $App.FullName -sync -skipVerification -install -checkAlreadyInstalled -IgnoreIfAppExists
                            }
                            catch {
                                if (!($_.Exception.Message.Contains('already published'))) {
                                    throw $_.Exception.Message
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

function Get-AppJsonForProjectAndRepo {
<#
.SYNOPSIS
    Fetches app.json for a given Azure DevOps project and repository from the default branch.
.PARAMETER ProjectName
    Short project name (resolved to a VSTS project via Get-ProjectName).
.PARAMETER RepositoryName
    Repository name within the project. Defaults to 'BC'.
.PARAMETER Publisher
    Pass 'Microsoft' to short-circuit and return an empty object.
#>

    Param(
        [Parameter(Mandatory=$true)]
        [string]$ProjectName,
        [Parameter(Mandatory=$false)]
        [string]$RepositoryName,
        [Parameter(Mandatory=$false)]
        [string]$Publisher
    )
    if ($Publisher -eq 'Microsoft') {
        return '{}'
    }
    
    $VSTSProjectName = Get-ProjectName $ProjectName

    if ($RepositoryName -eq '') {
        $RepositoryName = 'BC'
    }

    $AppContent = Invoke-TFSAPI ('{0}{1}/_apis/git/repositories/{2}/items?path=app/app.json' -f (Get-TFSCollectionURL), $VSTSProjectName, (Get-RepositoryId -ProjectName $VSTSProjectName -RepositoryName $RepositoryName)) -GetContents
    $AppJson = ConvertFrom-Json $AppContent
    $AppJson
}

function Get-EnvironmentJsonForProjectAndRepo {
<#
.SYNOPSIS
    Fetches environment.json for a given Azure DevOps project and repository and writes it
    to a temporary file, returning the directory path.
.DESCRIPTION
    Used to obtain per-project dependency overrides when resolving transitive dependencies.
    Returns the parent directory of the written file so it can be passed as SourcePath to
    Get-ALDependenciesFromAppJson. Returns an empty JSON object if the file does not exist.
.PARAMETER ProjectName
    Short project name (resolved to a VSTS project via Get-ProjectName).
.PARAMETER RepositoryName
    Repository name within the project. Defaults to 'BC'.
.PARAMETER Publisher
    Pass 'Microsoft' to short-circuit and return an empty object.
#>

    Param(
        [Parameter(Mandatory=$true)]
        [string]$ProjectName,
        [Parameter(Mandatory=$false)]
        [string]$RepositoryName,
        [Parameter(Mandatory=$false)]
        [string]$Publisher
    )
    if ($Publisher -eq 'Microsoft') {
        return '{}'
    }
    
    $VSTSProjectName = Get-ProjectName $ProjectName

    if ($RepositoryName -eq '') {
        $RepositoryName = 'BC'
    }

    $AppContent = Invoke-TFSAPI ('{0}{1}/_apis/git/repositories/{2}/items?path=app/environment.json' -f (Get-TFSCollectionURL), $VSTSProjectName, (Get-RepositoryId -ProjectName $VSTSProjectName -RepositoryName $RepositoryName)) -GetContents -SuppressError
    $FilePath = Join-Path (New-TempDirectory) ('environment.json' -f (New-Guid))
    if ($null -ne $AppContent) {
        Out-File -FilePath $FilePath -InputObject $AppContent
    }
    else {
        Out-File -FilePath $FilePath -InputObject '{}'
    }
    
    $FilePath = Split-Path -Path $FilePath -resolve
    $FilePath
}

function Get-DependencyFromEnvironment {
<#
.SYNOPSIS
    Returns the dependency override entry for a named app from the local environment.json.
.PARAMETER SourcePath
    Folder containing environment.json.
.PARAMETER Name
    App name to look up in the dependencies array.
#>

    Param(
        [Parameter(Mandatory=$true)]
        [string]$SourcePath,
        [Parameter(Mandatory=$true)]
        [string]$Name
    )

    Get-EnvironmentKeyValue -SourcePath $SourcePath -KeyName 'dependencies' | Where-Object name -eq $Name
}

Export-ModuleMember -Function Get-ALDependencies
Export-ModuleMember -Function Get-ALDependenciesFromAppJson
Export-ModuleMember -Function Get-AppJsonForProjectAndRepo
Export-ModuleMember -Function Get-EnvironmentJsonForProjectAndRepo