public/Import-OSDeployLicense.ps1

function Import-OSDeployLicense {
    <#
    .SYNOPSIS
        Imports a Recast Software license
 
    .DESCRIPTION
        Accepts a .license2 file or a ZIP archive containing a .license2 file, stages and
        validates the selected license, and interactively confirms copying it to the standard
        Recast Software license directory under ProgramData. After a successful copy, displays
        the license by calling Show-OSDeployLicense.
 
        ZIP archives are searched recursively. When an archive contains multiple license files,
        the candidate selected by Get-OSDCoreLicense is imported.
 
        Existing licenses are never renamed. Exact duplicates are removed. When licenses differ
        only by Expiration and Signature, the license with the newest expiration is retained.
 
        The imported file keeps its original filename by default. Only when a file with the
        same name already exists and is not being removed as a duplicate does the imported file
        get a numeric suffix such as -001.
 
    .PARAMETER LicenseFile
        Specifies the path to a .license2 file or a ZIP archive containing one or more license
        files with the .license2 extension. The path must identify an existing file.
 
    .EXAMPLE
        PS> Import-OSDeployLicense -LicenseFile 'C:\Downloads\CommunityLicense.license2'
 
        Validates the license, asks for confirmation, and imports it into the standard license
        directory.
 
    .EXAMPLE
        PS> Import-OSDeployLicense -LicenseFile 'C:\Downloads\CommunityLicense.zip'
 
        Extracts the archive to a temporary directory, selects and validates a contained
        license, asks for confirmation, and imports it.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject. After a successful import, returns the
        valid license object produced by Show-OSDeployLicense.
 
    .NOTES
        Author: David Segura
        Company: Recast Software
        Version: 1.0.0
        Date: 2026-09-02
 
        Write access to the ProgramData license directory is required.
 
    .LINK
        Show-OSDeployLicense
 
    .LINK
        https://www.osdeploy.com/guide/registration
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
        [string]$LicenseFile
    )

    Write-Verbose "[$($MyInvocation.MyCommand.Name)] Start"

    $SourceFile = Get-Item -LiteralPath $LicenseFile -ErrorAction Stop
    $SourceExtension = $SourceFile.Extension.ToLowerInvariant()
    if ($SourceExtension -notin @('.license2', '.zip')) {
        Write-Warning "[$(Get-Date -Format s)] LicenseFile must be a .license2 file or a ZIP archive."
        return
    }

    $StagingPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "OSDeployLicense-$([guid]::NewGuid().ToString('N'))"
    $DestinationDirectory = Join-Path -Path $env:ProgramData -ChildPath 'Recast Software\Licenses'

    try {
        $null = New-Item -Path $StagingPath -ItemType Directory -Force -ErrorAction Stop
        $ValidationPath = Join-Path -Path $StagingPath -ChildPath 'Licenses'
        $null = New-Item -Path $ValidationPath -ItemType Directory -Force -ErrorAction Stop

        if ($SourceExtension -eq '.zip') {
            $ExtractionPath = Join-Path -Path $StagingPath -ChildPath 'Archive'
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Extracting license archive: $($SourceFile.FullName)"
            Expand-Archive -LiteralPath $SourceFile.FullName -DestinationPath $ExtractionPath -Force -ErrorAction Stop
            $ArchiveLicenses = @(Get-ChildItem -LiteralPath $ExtractionPath -Filter '*.license2' -File -Recurse -ErrorAction Stop)
            if (-not $ArchiveLicenses) {
                Write-Warning "[$(Get-Date -Format s)] No .license2 file was found in $($SourceFile.FullName)."
                return
            }

            foreach ($ArchiveLicense in $ArchiveLicenses) {
                $StagedName = $ArchiveLicense.Name
                if (Test-Path -LiteralPath (Join-Path -Path $ValidationPath -ChildPath $StagedName)) {
                    $StagedName = "{0}-{1}{2}" -f $ArchiveLicense.BaseName, ([guid]::NewGuid().ToString('N')), $ArchiveLicense.Extension
                }
                Copy-Item -LiteralPath $ArchiveLicense.FullName -Destination (Join-Path -Path $ValidationPath -ChildPath $StagedName) -Force -ErrorAction Stop
            }
        }
        else {
            Copy-Item -LiteralPath $SourceFile.FullName -Destination $ValidationPath -Force -ErrorAction Stop
        }

        $StagedLicense = Get-OSDCoreLicense -Path $ValidationPath
        if (-not $StagedLicense) {
            Write-Warning "[$(Get-Date -Format s)] No readable .license2 file was found in $($SourceFile.FullName)."
            return
        }

        if (-not $StagedLicense.IsValid) {
            $ValidationMessage = @($StagedLicense.ValidationErrors) -join '; '
            Write-Warning "[$(Get-Date -Format s)] The selected license is not valid: $ValidationMessage"
            return
        }

        $IncomingRecord = @(Get-OSDeployLicenseFileRecord -LiteralPath $StagedLicense.FullName | Where-Object { $_.LicenseGuid -eq $StagedLicense.LicenseGuid }) | Select-Object -First 1
        if (-not $IncomingRecord -or $IncomingRecord.EntryCount -ne 1) {
            Write-Warning "[$(Get-Date -Format s)] The selected license file must contain exactly one license payload."
            return
        }
        $ExistingFiles = @(Get-ChildItem -LiteralPath $DestinationDirectory -Filter '*.license2' -File -ErrorAction SilentlyContinue)
        $UnmanagedFiles = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
        $Candidates = [System.Collections.Generic.List[object]]::new()

        foreach ($ExistingFile in $ExistingFiles) {
            $FileRecords = @(Get-OSDeployLicenseFileRecord -LiteralPath $ExistingFile.FullName)
            if ($FileRecords.Count -ne 1 -or $FileRecords[0].EntryCount -ne 1) {
                $UnmanagedFiles.Add($ExistingFile)
                Write-Warning "[$(Get-Date -Format s)] Unable to compare unreadable or multi-license file: $($ExistingFile.FullName)"
                continue
            }

            $Candidates.Add([pscustomobject]@{
                    Record             = $FileRecords[0]
                    File               = $ExistingFile
                    IsIncoming         = $false
                    LicenseGuid        = $FileRecords[0].LicenseGuid
                    Expiration         = $FileRecords[0].Expiration
                    ExactFingerprint   = $FileRecords[0].ExactFingerprint
                    RenewalFingerprint = $FileRecords[0].RenewalFingerprint
                    TargetFile         = $null
                })
        }

        $IncomingCandidate = [pscustomobject]@{
            Record             = $IncomingRecord
            File               = $IncomingRecord.File
            IsIncoming         = $true
            LicenseGuid        = $IncomingRecord.LicenseGuid
            Expiration         = $IncomingRecord.Expiration
            ExactFingerprint   = $IncomingRecord.ExactFingerprint
            RenewalFingerprint = $IncomingRecord.RenewalFingerprint
            TargetFile         = $null
        }
        $Candidates.Add($IncomingCandidate)

        $Survivors = [System.Collections.Generic.List[object]]::new()
        foreach ($GuidGroup in @($Candidates | Group-Object -Property LicenseGuid)) {
            foreach ($RenewalGroup in @($GuidGroup.Group | Group-Object -Property RenewalFingerprint)) {
                $Winner = $RenewalGroup.Group |
                Sort-Object -Property @(
                    @{ Expression = { $_.Expiration }; Descending = $true },
                    @{ Expression = { $_.IsIncoming }; Ascending = $true },
                    @{ Expression = { $_.File.LastWriteTime }; Descending = $true }
                ) |
                Select-Object -First 1
                $Survivors.Add($Winner)
            }
        }

        $SurvivingExistingPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($Survivor in @($Survivors | Where-Object { -not $_.IsIncoming })) {
            $null = $SurvivingExistingPaths.Add($Survivor.File.FullName)
        }
        $FilesToRemove = @($Candidates | Where-Object { -not $_.IsIncoming -and -not $SurvivingExistingPaths.Contains($_.File.FullName) } | Select-Object -ExpandProperty File -Unique)

        $ImportIncoming = $Survivors.Contains($IncomingCandidate)
        $DestinationFile = $null
        $RenamedIncoming = $false
        if ($ImportIncoming) {
            $RemovalPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($FileToRemove in $FilesToRemove) {
                $null = $RemovalPaths.Add($FileToRemove.FullName)
            }

            $DesiredName = $StagedLicense.FileName
            $BaseName = [System.IO.Path]::GetFileNameWithoutExtension($DesiredName)
            $Extension = [System.IO.Path]::GetExtension($DesiredName)

            $CollisionIndex = 0
            do {
                $TargetName = if ($CollisionIndex -eq 0) {
                    $DesiredName
                }
                else {
                    '{0}-{1:000}{2}' -f $BaseName, $CollisionIndex, $Extension
                }
                $CollisionIndex++
                $CandidatePath = Join-Path -Path $DestinationDirectory -ChildPath $TargetName
            } while ((Test-Path -LiteralPath $CandidatePath -PathType Leaf) -and -not $RemovalPaths.Contains($CandidatePath))

            $DestinationFile = $CandidatePath
            $RenamedIncoming = -not [string]::Equals($TargetName, $DesiredName, [System.StringComparison]::OrdinalIgnoreCase)
        }

        Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] The validated license is ready to import."
        Write-Host -ForegroundColor DarkGray "Source : $($SourceFile.FullName)"
        Write-Host -ForegroundColor DarkGray "License : $($StagedLicense.FullName)"
        Write-Host -ForegroundColor DarkGray "Guid : $($IncomingRecord.LicenseGuid)"
        Write-Host -ForegroundColor DarkGray "Destination : $(if ($DestinationFile) { $DestinationFile } else { 'No copy; an identical or newer license already exists' })"
        Write-Host -ForegroundColor DarkGray "Duplicates : $($FilesToRemove.Count) file(s) will be removed"
        if ($RenamedIncoming) {
            Write-Host -ForegroundColor DarkGray "Rename : Incoming file name collision detected; using $([System.IO.Path]::GetFileName($DestinationFile))"
        }

        $HasDirectoryChanges = $FilesToRemove.Count -gt 0
        if (-not $ImportIncoming -and -not $HasDirectoryChanges) {
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Import skipped because an identical or newer license already exists."
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Testing the installed license with Show-OSDeployLicense"
            return Show-OSDeployLicense
        }

        $ConfirmCaption = if ($ImportIncoming) {
            "Import License - $($StagedLicense.FileName)"
        }
        else {
            'Clean Duplicate Licenses'
        }
        $ConfirmQuestion = if ($ImportIncoming) {
            'Apply these license directory changes and import the license?'
        }
        else {
            'Apply these duplicate cleanup changes?'
        }
        $ConfirmMessage = "Source : $($SourceFile.FullName)`nGuid : $($IncomingRecord.LicenseGuid)`nDestination : $(if ($DestinationFile) { $DestinationFile } else { 'No copy; identical or newer license exists' })`nDuplicates : $($FilesToRemove.Count) file(s) will be removed`nRename : $(if ($RenamedIncoming) { 'Incoming file will use incrementing suffix' } else { 'None' })`n`n$ConfirmQuestion"
        if (-not $PSCmdlet.ShouldContinue($ConfirmMessage, $ConfirmCaption)) {
            Write-Verbose "[$($MyInvocation.MyCommand.Name)] User declined: import license"
            return
        }

        if (-not $PSCmdlet.ShouldProcess($DestinationDirectory, 'Clean duplicate licenses and import Recast Software license')) {
            return
        }

        $null = New-Item -Path $DestinationDirectory -ItemType Directory -Force -ErrorAction Stop

        foreach ($FileToRemove in $FilesToRemove) {
            Remove-Item -LiteralPath $FileToRemove.FullName -Force -ErrorAction Stop
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Removed duplicate license: $($FileToRemove.FullName)"
        }

        if ($ImportIncoming) {
            Copy-Item -LiteralPath $StagedLicense.FullName -Destination $DestinationFile -Force -ErrorAction Stop
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [SUCCESS] License copied successfully to $DestinationFile"
        }
        else {
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Import skipped because an identical or newer license already exists."
        }
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Testing the installed license with Show-OSDeployLicense"

        Show-OSDeployLicense
    }
    catch {
        Write-Warning "[$(Get-Date -Format s)] Unable to import the license. $($_.Exception.Message)"
    }
    finally {
        if (Test-Path -LiteralPath $StagingPath -PathType Container) {
            Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
        }
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] End"
    }
}