Functions/PlatformManagement/Publish-BcAddin.ps1

<#
    .Synopsis
    Installs DotNet Assemblies into the Microsoft Dynamics 365 Business Central Addin folder.
     
    .Description
    The parameter Path is the source path of all dotnet addins.
    The target folder (root add-in folder) can be supplied directly with param Destination,
     or can be received dynamically based on the BC version or a Business Central Service Tier.
 
    .Example
    Publish-BcAddin -Path 'c:\release\dotnet\add-in' -ServerInstance 'BC210Test'
 
    .Example
    Publish-BcAddin `
        -Path @('C:\dotnet\PDF On-Premises', 'C:\dotnet\SharePoint On-Premises') `
        -Destination 'C:\Program Files\Microsoft Dynamics 365 Business Central\210\Service\Add-ins'
 
    .Example
    Publish-BcAddin -Path 'c:\release\dotnet' -BcVersion 'bc21'
 
#>


function Publish-BcAddin {

    [CmdletBinding(DefaultParameterSetName='ServerInstance')]
    param(
        # The path to the folder(s) to publish to the add-in folder.
        # E.g. 'C:\dotnet\PDF On-Premises' or @('C:\dotnet\PDF On-Premises', 'C:\dotnet\SharePoint On-Premises')
        [Parameter(Mandatory = $true)]
        [string[]] $Path,

        # Publishes the assemblies into the supplied BC addinfolder.
        # E.g. 'C:\Program Files\Microsoft Dynamics 365 Business Central\160\Service\Add-ins'
        [Parameter(ParameterSetName='Destination', Mandatory = $true)]
        [string] $Destination,

        # Publishes the assemblies into the BC addinfolder compatible with the supplied serverinstance.
        # E.g. 'BC170'
        [Parameter(ParameterSetName='ServerInstance', Mandatory = $true)]
        [string] $ServerInstance,
        
        # Publishes the assemblies into the BC addinfolder compatible with the supplied BcVersion.
        # E.g. 'bc17' or 'bc15'.
        [Parameter(ParameterSetName='BcVersion', Mandatory = $true)]
        [String] $BcVersion,

        # Location to write the logfile to. Default location is '?:\ProgramData\4ps\dotnetdeployment'.
        # E.g. 'C:\BcInstallation\Log'
        [string] $LogFilePath = (Join-Path -Path $env:ProgramData -ChildPath '4ps\dotnetdeployment'),

        # When enabled and when the target folder already exists than the target folder will be removed first.
        [switch] $Force
    )

    "
      _____ _ _ _ _ _ _ _ _ _ _
     | __ \ | | | (_) | | | \ | | | | /\ | | | (_)
     | |__) | _| |__ | |_ ___| |__ | \| | ___| |_ / \ __| | __| |_ _ __ ___
     | ___/ | | | '_ \| | / __| '_ \ | . `` |/ _ \ __| / /\ \ / _`` |/ _`` | | '_ \/ __|
     | | | |_| | |_) | | \__ \ | | | _| |\ | __/ |_ / ____ \ (_| | (_| | | | | \__ \
     |_| \__,_|_.__/|_|_|___/_| |_| (_)_| \_|\___|\__| /_/ \_\__,_|\__,_|_|_| |_|___/
        4PS v{0}
    "
 -f $MyInvocation.MyCommand.Module.Version | Write-Host

    Test-Preconditions -Is64Bit -HasElevatedRights -ValidPowerShellVersion -HasFullLanguage -MinimalMajorVersion 5 -MinimalMinorVersion 0 -Verbose -ErrorAction Stop
    
    try{ Stop-Transcript }catch{}

    # Validate if module FpsGeneral is available on which BcDeployment depends
    if(-not (Get-Module FpsGeneral)){
        if(Get-Module -ListAvailable FpsGeneral){
            Import-Module FpsGeneral -DisableNameChecking -Force
        } else{
            Write-Error 'Please install and import PowerShell module FpsGeneral before executing this cmdlet.'
            return
        }
    }

    # Create logfile
    $LogFile = New-FpsLogFile -LogFilePath $LogFilePath -LogFileNameSuffix 'fps' -LogFilesToPreserve 10
    Start-Transcript -path $LogFile -append

    ### Start script
    if($Destination){
        'Supplied destination: ''{0}''.' -f $Destination | Write-Host
        
        if((Split-Path $Destination -Leaf) -ne '_4PS'){
            $Destination = Join-Path $Destination '_4PS'
        }
    } else{
        'Destination is not set. Retreiving destination dynamically...' | Write-Host
    
        if($BcVersion){
            if((Get-BcVersion -BcVersion $BcVersion) -eq $false){
                "The supplied BC/NAV version '{0}' is not valid. Valid values are for example 'BC21', 'BC19', 'NAV2017'" -f 
                    $BcVersion | Write-Warning
                return
            }
            [hashtable] $BcVersion = Get-BcVersion -BcVersion $BcVersion
        }

        if($ServerInstance){
            $BcVersionFolder = ([version](Get-BCServerInstance -ServerInstance $ServerInstance).Version).Major * 10
            [hashtable] $BcVersion = Get-BcVersion -VersionFolder $BcVersionFolder
        }

        # Get installation folder
        $Service = Get-BcComponent -BcVersion $BcVersion.Version | Where-Object Component -eq 'NST'

        if (-not $Service.IsInstalled) {
            Write-Warning 'Cannot find BC add-in folder because the BC Service folder is not found. Make sure the BC Service is installed.'    
            return $false
        }

        $Destination = Join-Path $Service.InstallLocation 'Add-ins\_4PS'

    }

    'Destination is set to ''{0}''.' -f $Destination | Write-Host

    # Test if source and target folder exists
    $Path | ForEach-Object {
        if((Test-Path $_) -eq $false){
            $msg = 'Cannot find assembly path. Please check the path ''{0}''' -f $_
            Write-Error $msg
        } else {
            'Source path ''{0}'' exists.' -f $_ | Write-Host
        }
    }

    if((Test-Path (Split-Path $Destination -Parent)) -eq $false){
        $msg = 'Cannot find the destination path. Please check the path ''{0}''' -f (Split-Path $Destination -Parent)
        Write-Error $msg
    } else {
        'Root add-in directory ''{0}'' exists.' -f (Split-Path $Destination -Parent) | Write-Host
    }

    if((Test-Path $Destination) -eq $false){
        'Folder ''{0}'' doesn''s exist in root add-in folder. Creating folder..' -f (Split-Path $Destination -Leaf) | Write-Host
        New-Item $Destination -ItemType Directory -Force
    } else {
        'Destination ''{0}'' exists.' -f $Destination | Write-Host
    }

    # Copy source folders to destination

    foreach($dir in $Path){
        
        "`nUpdating components {0}..." -f ($dir | Split-Path -Leaf)
        
        # If the folder starts with a number, add a '_' prefix.
        if((Split-Path $dir -Leaf) -match '^[0-9]+.*'){
            $target = Join-Path $Destination ('_{0}' -f (Split-Path $dir -Leaf))
        } else {
            $target = Join-Path $Destination (Split-Path $dir -Leaf)
        }

        $filesToCopy = Get-ChildItem -Path $dir -Recurse
        $lockedFiles = @()

        # If the folder exists it should be removed unless the source and target are the same.
        if((Test-Path $target) -eq $true){
            if(-not $Force){
                $msg  = 'The destination folder ''{0}'' already exists. ' -f $target
                $msg += 'Please remove the folder first or use the -Force parameter to replace the folder.'
                $msg | Write-Warning
                continue
            }

            'The destination folder ''{0}'' already exists.' -f $target | Write-Host

            # Compare hashes of source and destination directory
            $sourceHashes = Get-DirectoryHash -Path $dir
            $targetHashes = Get-DirectoryHash -Path $target

            if (-not (Compare-Object -ReferenceObject $sourceHashes -DifferenceObject $targetHashes -SyncWindow 0)) {
                'Source and destination folders are identical, skipping.' | Write-Host
                continue
            }

            'Files in the source and destination are not the same, removing the destination folder.' | 
                Write-Host

            Remove-Item -Path $target -Recurse -Force -ErrorAction SilentlyContinue
        }

        "Copying files from: `n '{0}' to `n '{1}'" -f $dir, $target | Write-Host

        foreach ($file in $filesToCopy) {
            
            # Calculate relative path
            $relativePath = $file.FullName.Substring($dir.Length)

            # Create full destination path
            $destinationPath = Join-Path $target $relativePath

            # Ensure that the destination directory exists
            $destinationDir = Split-Path -Path $destinationPath -Parent
            if (!(Test-Path $destinationDir)) {
                $null = New-Item -ItemType Directory -Path $destinationDir -ErrorAction Continue | Out-Null
            }

            try {
                Copy-Item -Path $file.FullName -Destination $destinationPath -ErrorAction Stop
            }
            catch {
                # Assume any errors are due to the file being locked
                $lockedFiles += $destinationPath 
            }
        }

        if ($lockedFiles) {
            $message = "The following files could not be copied to the destination: `n{0}" -f 
                            $($lockedFiles -join ', ')
            $message += "`nCheck if the files are still locked. Unluck the files and try again."
            Write-Error $message
        } else {
            'Done' | Write-Host
        }

        # If the add-in folder contains .zip files, extract the archives.
        $archives = Get-ChildItem -Path $target -Recurse -Filter '*.zip'
        
        if($archives.Count -ge 1){
            'There are {0} .zip archives found in the folder ''{1}''. Extracting the content...' -f $archives.Count, (Split-Path $target -Leaf) | Write-Host
        }

        $archives | ForEach-Object {
            
            $archiveTarget = Join-Path $target ([io.path]::GetFileNameWithoutExtension($_))

            if((Test-Path $archiveTarget) -eq $false){
                New-Item $archiveTarget -ItemType Directory -Force
            }
            
            if ((Get-Command Expand-Archive).Source -eq 'Pscx'){
                Expand-Archive -Path $_.FullName -OutputPath $archiveTarget -Force
            } else {
                Expand-Archive -Path $_.FullName -DestinationPath $archiveTarget -Force
            }

            Remove-Item $_.FullName -Force
            
        }
    }
    
    'Installation completed.' | Write-Host

    try{ Stop-Transcript }catch{}
}

Export-ModuleMember -Function Publish-BcAddin

function Get-DirectoryHash($Path){
    $hashes = Get-ChildItem -Path $Path -File -Recurse | Get-FileHash | Select-Object -ExpandProperty Hash
    $sortedHashes = $hashes | Sort-Object
    return $sortedHashes
}