Functions/ModuleManagement/Install-FpsTools.ps1

<#
.SYNOPSIS
    Installs or updates the 4PS Powershell Tools from Azure DevOps on local system for the current user,
     and creates a shortcut on the desktop to the destination folder.
.DESCRIPTION
    1. Create the destination folder if not exists.
        (i) Except if SkipScriptInstallation is set.
 
    2. Test if files in the destination folder are locked or not.
        (i) Except if SkipScriptInstallation is set.
 
    3. Obtains Azure DevOps PAT token.
        (i) Automatically or through users input.
 
    4. (Re)installs the PowerShell repository/feed.
 
    5. Installs or updates the PowerShell modules
        (i) If ModuleFilter is set only the modules in the filter are installed.
 
    6. Copies the module script folder into the DestinationPath
        (i) Except if SkipScriptInstallation is set.
        a. Creates a backup of the module folder in DestinationPath.
        b. Clears the module folder in DestinationPath.
        c. Clears the DestinationPath
            (i) Only if ClearDestination is set.
        d. Copies the scripts from the PS Module folder to the DestinationPath.
        e. Creates a shortcut to the users desktop
            (i) Except if SkipShortcut is set.
.EXAMPLE
    Install-FpsTools `
        -AzDoPatMethod 'CredentialManager' `
        -ModuleFilter @('FpsBcGeneral', 'FpsDevelopment', 'FpsNavInternal') `
        -AzDoFeedName 'fpstools_internal' `
        -AzDoProjectName '4PS Tools'
 
.EXAMPLE
    Install-FpsTools `
        -DestinationPath (Join-Path -Path ([Environment]::GetFolderPath("mydocuments")) -ChildPath 'FpsNavDevelopmentTools')
        -ModuleFilter @('FpsDevelopment')
        -AzDoPatMethod 'CredentialManager' `
        -AzDoFeedName 'fpstools_internal' `
        -AzDoProjectName '4PS Tools' `
        -ClearDestination `
        -ErrorAction Stop
 
.EXAMPLE
    Install-FpsTools `
        -AzDoPatMethod 'CredentialManager' `
        -AzDoFeedName 'fpstools_internal' `
        -AzDoProjectName '4PS Tools' `
        -SkipScriptInstallation
#>


function Install-FpsTools {
    [CmdletBinding()]
    param (
        # Destination Path for the PowerShell Scripts to be installed in. Default is the '4PS PowerShell Tools' folder in your documents.
        [string] $DestinationPath  = (Join-Path `
                                        -Path ([Environment]::GetFolderPath("mydocuments")) `
                                        -ChildPath '4PS PowerShell Tools'),

        # Option CredentialManager stores a manual suplied PAT token in Windows Credential Manager. Option Az.Accounts generates a temporary PAT token.
        [ValidateSet('CredentialManager', 'Az.Accounts')]
        [string] $AzDoPatMethod    = 'CredentialManager',

        # Filter on the modules to install. Default installs all modules from feed.
        [string[]] $ModuleFilter,

        # Azure DevOps Artifact Feed name
        [string] $AzDoFeedName     = 'fpstools_internal',    

        # Azure DevOps organization name
        [string] $AzDoOrganisation = '4psnl',
        
        # Azure DevOps project name
        [string] $AzDoProjectName  = '4PS Tools',

        # For a clean installation enable ClearDestination. This will purge the content of $DestinationPath before installing the modules.
        [switch] $ClearDestination,

        # Disables the creation of the shortcut on the desktop to the destionation folder
        [switch] $SkipShortcut,

        # Disables the installation of the PowerShell scripts to the destination folder.
        [switch] $SkipScriptInstallation
    )

    if($SkipScriptInstallation){$SkipShortcut = $true}

    $TaskStartTime = Write-StartProcessLine -StartLogText ('Check if folder ''{0}'' exists and is editable.' -f 
                                                            (Split-Path -Path $destinationPath -Leaf))
    if ($SkipScriptInstallation -eq $false) {
        # Create destination folder if not exists
        if (-not (Test-Path $DestinationPath)) {
            'Folder created: {0}' -f $DestinationPath | Write-Host
            New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null
        } 

        # Test if items in destination folder are not locked by another process
        $lockedFiles = Test-ReadWriteAccessFile -Path $destinationPath
        if($lockedFiles -ne $false){
            
            $msg = @()
            $msg += 'One or more files in folder {0} are still locked by another process. Please close other processes before continuing' -f 
                        (Split-Path -Path $destinationPath -Leaf) | Write-Host
            $msg += 'The following files are still locked: {0}' -f 
                        $($LockedFiles | Out-String)
            Write-Error ($msg | Out-String)

        } else {
            'Items in folder ''{0}'' are not locked by other processes' -f 
                (Split-Path -Path $destinationPath -Leaf) | Write-Host
        }
    }
    Write-EndProcessLine -TaskStartTime $TaskStartTime
    
    $TaskStartTime = Write-StartProcessLine -StartLogText ('Retrieving Personal Access Token (PAT) with method: {0}' -f $AzDoPatMethod)
        # Get Azure DevOps PAT token
        $personalAccessToken = Get-FpsAzDoPat -method $AzDoPatMethod 
        
        # Test if Azure DevOps API is accessible with PAT
        $result = Test-FpsAzDoPat `
                    -PersonalAccessToken $personalAccessToken `
                    -AzDoOrganisation $AzDoOrganisation
        
        if ($result -eq $false){
            switch ($AzDoPatMethod){
                'Az.Accounts' {
                    $msg = @()
                    $msg += 'Personal Access Token is not valid.'
                    $msg += 'Please validate if you have access to the artifact feed ''{0}'' in project ''{1}'' on Azure DevOps' -f 
                                $AzDoFeedName, $AzDoProjectName
                    $msg += 'Or try to use the alternative Azure DevOps PAT method ''CredentialManager''.'
                    Write-Error ($msg | Out-String)
                }
                'CredentialManager' {
                    Set-FpsAzDoPat 
                    $personalAccessToken = Get-FpsAzDoPat -method $AzDoPatMethod 
                }
            }
        }

        # Test if Azure DevOps Feed is accessible
        $result = Test-FpsAzDoPatFeedAccess `
                    -PersonalAccessToken $personalAccessToken `
                    -AzDoOrganisation $AzDoOrganisation `
                    -AzDoProjectName  $AzDoProjectName `
                    -AzDoFeedName     $AzDoFeedName `
                    -ErrorAction stop

        if ($result -eq $false){
            $msg = 'Please validate if you have access to the artifact feed ''{0}'' in project ''{1}'' on Azure DevOps' -f 
                                $AzDoFeedName, $AzDoProjectName
            Write-Error ($msg | Out-String)
        }
        
        # Create credential object
        $feedUsername = 'dummy@4ps.nl'
        $feedPassword = ConvertTo-SecureString -String $personalAccessToken -AsPlainText -Force
        $feedCredential = New-Object System.Management.Automation.PSCredential ($feedUsername, $feedPassword)
    Write-EndProcessLine -TaskStartTime $TaskStartTime

    $TaskStartTime = Write-StartProcessLine -StartLogText 'Install the Azure DevOps Artifact feed URL (Nuget)'
        Install-FpsRepository `
            -AzDoOrganisation $AzDoOrganisation `
            -AzDoProjectName  $AzDoProjectName `
            -AzDoFeedName     $AzDoFeedName `
            -Credential       $feedCredential
    Write-EndProcessLine -TaskStartTime $TaskStartTime

    $TaskStartTime = Write-StartProcessLine -StartLogText 'Install the PowerShell modules from Artifact feed.'
        # Get modules
        $modulesInFeed = Find-Module -Repository $AzDoFeedName -Credential $feedCredential
        
        # Apply module filter
        if([string]::IsNullOrEmpty($ModuleFilter)){
            $modules = $modulesInFeed
        } else {
            $modules = $modulesInFeed | Where-Object -Property Name -in $ModuleFilter
        }

        "The following modules are found in feed {0}: `n{1}" -f 
            $AzDoFeedName, ($modules.Name | Out-String) | Write-Host

        # Install and import modules
        Import-FpsModule `
            -ModuleNames $modules.Name `
            -Repository $AzDoFeedName `
            -feedCredential $feedCredential `
            -Force
    Write-EndProcessLine -TaskStartTime $TaskStartTime

    $TaskStartTime = Write-StartProcessLine -StartLogText 'Creating a backup of local scripts and clear folder'
    if ($SkipScriptInstallation -eq $false) {
        # Backup and clear installed module script folders
        $backupDestination = Join-Path -Path $env:LOCALAPPDATA -ChildPath ('4ps\fpstools\backup\{0}' -f (Get-Date).ToString('yyyy-MM-dd_HH.mm.ss'))

        'Creating a backup of the PowerShell script folder to ''{0}''' -f $backupDestination | Write-Host
        foreach ($moduleName in $modules.Name){
            $scriptPath = Join-Path -Path $DestinationPath -ChildPath $moduleName
            
            if((Test-Path $scriptPath) -eq $false){ continue }

            # Create backup folder if not exists
            if((Test-Path $backupDestination) -eq $false){
                New-Item -Path $backupDestination -ItemType Directory -Force | Out-Null
            }
            
            ' Create backup for scripts from module ''{0}''' -f $moduleName | Write-Host
            Copy-Item -Path $scriptPath -Destination $backupDestination -Recurse -Force -ErrorAction Stop

            ' Clearing PowerShell scripts from ''{0}''' -f $scriptPath | Write-Host
            Remove-Item (Join-Path $scriptPath '*') -Recurse -Force
        }

        if($ClearDestination){
            'ClearDestination enabled, clearing folder ''{0}''' -f $DestinationPath | Write-Host
            Remove-Item (Join-Path $DestinationPath '*') -Recurse -Force
        }

        # Remove older backups (keep max 6 backups)
        if((Test-Path $backupDestination) -eq $true){
            $backups = Get-ChildItem (Split-Path -Parent $backupDestination)
            if($Backups.Count -gt 6){
                $exclude = $backups | Sort-Object -Property CreationTime -Descending | Select-Object -First 6
                Get-ChildItem (Split-Path -Parent $backupDestination) -Exclude $exclude | Remove-Item -Recurse -Force 
            }
      }
    }
    Write-EndProcessLine -TaskStartTime $TaskStartTime

    $TaskStartTime = Write-StartProcessLine -StartLogText ('Copying script folder(s) to destination ''{0}''' -f (Split-Path -Path $destinationPath -Leaf))
    if ($SkipScriptInstallation -eq $false) {
        foreach ($moduleName in $modules.Name){
            
            # Get module script folder
            $moduleBase = (Get-Module -Name $moduleName -ListAvailable | 
                                Sort-Object -Property Version -Descending | 
                                Select-Object -First 1).ModuleBase
            $moduleScriptPath = Join-Path -Path $moduleBase -ChildPath 'Scripts'

            if(Test-Path $moduleScriptPath){
                'Module {0} contains a script folder.' -f $moduleName | Write-Host

                $scriptDestionationPath = Join-Path -Path $DestinationPath -ChildPath $moduleName

                if((Test-Path $scriptDestionationPath) -eq $false){
                    New-Item -Path $scriptDestionationPath -ItemType Directory -Force | Out-Null
                }

                "Copying scripts for module {0}`n Source: '{1}'`n Target: '{2}'" -f $moduleName, $moduleScriptPath, $scriptDestionationPath | Write-Host
                Get-ChildItem $moduleScriptPath | ForEach-Object {
                    Copy-Item -Path $_.FullName -Destination $scriptDestionationPath -Recurse -Force | Out-Null
                }

                continue
            }

            'Module {0} does not contain a script folder.' -f $moduleName | Write-Host
        }
    } else {
        'Installation of the PowerShell scripts to the destionation folder is skipped.' | Write-Host
    }

    # Create shortcut to the destination folder
    if ($SkipShortcut -eq $false) {
        $linkPath = Join-Path `
                        -Path ([Environment]::GetFolderPath('Desktop')) `
                        -ChildPath ('{0}.lnk' -f (Split-Path $DestinationPath -Leaf))

        if (Test-Path $linkPath) {
            Remove-Item $linkPath
        }

        $wshShell = New-Object -comObject WScript.Shell
        $shortcut = $wshShell.CreateShortcut($linkPath)
        $shortcut.TargetPath = $DestinationPath
        $shortcut.Save()

        'A hyperlink to the PowerShell scripts is created on your desktop' | Write-Host
    } else {
        'Creation of the hyperlink on the desktop is skipped.' | Write-Host
    }
    Write-EndProcessLine -TaskStartTime $TaskStartTime
}

Export-ModuleMember -Function Install-FpsTools