Public/Publish-RobopackPackage.ps1

function Publish-RobopackPackage {
    <#
    .SYNOPSIS
    Publishes a package using create, chunk upload, and finalize lifecycle.
 
    .DESCRIPTION
    Validates a local package, creates a remote package record using metadata,
    uploads package content in chunks, and finalizes the upload.
 
    .PARAMETER ApiKey
    The API key for the Robopack instance.
 
    .PARAMETER PackagePath
    Path to the local package folder.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$PackagePath
    )

    $chunkSizeBytes = 4MB
    $maxRetriesPerChunk = 2
    $packageId = $null

    if (-not (Test-Path -Path $PackagePath)) {
        throw "Package path does not exist: $PackagePath"
    }

    if (-not (Test-RobopackPackage -Path $PackagePath)) {
        throw "Package validation failed. Cannot publish."
    }

    $resolvedPackagePath = (Resolve-Path -Path $PackagePath).Path

    $metadataPath = Join-Path $resolvedPackagePath "metadata.json"
    if (-not (Test-Path -Path $metadataPath)) {
        throw "metadata.json is missing in package folder: $resolvedPackagePath"
    }

    $contentPath = Join-Path $resolvedPackagePath "Content"
    if (-not (Test-Path -Path $contentPath)) {
        throw "Content folder is missing in package folder: $resolvedPackagePath"
    }

    $filesToUpload = Get-ChildItem -Path $contentPath -File -Recurse | Sort-Object FullName
    if ($filesToUpload.Count -eq 0) {
        throw "No files found in Content folder. Add installer/content files before publish."
    }

    $rawMetadata = Get-Content -Path $metadataPath -Raw | ConvertFrom-Json -AsHashtable

    $name = if ($rawMetadata.fullProductName) { [string]$rawMetadata.fullProductName } elseif ($rawMetadata.productName) { [string]$rawMetadata.productName } else { [string]$rawMetadata.name }
    $publisher = if ($rawMetadata.manufacturer) { [string]$rawMetadata.manufacturer } elseif ($rawMetadata.publisher) { [string]$rawMetadata.publisher } else { $null }
    $version = if ($rawMetadata.productVersion) { [string]$rawMetadata.productVersion } elseif ($rawMetadata.version) { [string]$rawMetadata.version } else { $null }
    $installCommand = if ($rawMetadata.installCommand) { [string]$rawMetadata.installCommand } elseif ($rawMetadata.install) { [string]$rawMetadata.install } else { $null }
    $uninstallCommand = if ($rawMetadata.uninstallCommand) { [string]$rawMetadata.uninstallCommand } elseif ($rawMetadata.uninstall) { [string]$rawMetadata.uninstall } else { $null }

    $inferredSourceType = "zip"
    if ($filesToUpload.Count -eq 1) {
        $ext = $filesToUpload[0].Extension.ToLowerInvariant()
        if ($ext -eq ".msi") { $inferredSourceType = "msi" }
        elseif ($ext -eq ".exe") { $inferredSourceType = "exe" }
        elseif ($ext -eq ".zip") { $inferredSourceType = "zip" }
    }

    $sourceType = if ($rawMetadata.sourceType) { [string]$rawMetadata.sourceType } else { $inferredSourceType }

    $allowedSourceTypes = @("msi", "zip", "exe")
    if ($allowedSourceTypes -notcontains $sourceType.ToLowerInvariant()) {
        throw "Invalid sourceType '$sourceType'. Allowed values are: msi, zip, exe."
    }

    $createMetadata = @{
        name       = $name
        sourceType = $sourceType.ToLowerInvariant()
    }

    if (-not [string]::IsNullOrWhiteSpace($publisher)) {
        $createMetadata.publisher = $publisher
    }

    if (-not [string]::IsNullOrWhiteSpace($version)) {
        $createMetadata.version = $version
    }

    if (-not [string]::IsNullOrWhiteSpace($installCommand)) {
        $createMetadata.installCommand = $installCommand
    }

    if (-not [string]::IsNullOrWhiteSpace($uninstallCommand)) {
        $createMetadata.uninstallCommand = $uninstallCommand
    }

    if ($rawMetadata.ContainsKey("userContext")) {
        $createMetadata.userContext = [bool]$rawMetadata.userContext
    }

    if ($rawMetadata.ContainsKey("runAnalysis")) {
        $createMetadata.runAnalysis = [bool]$rawMetadata.runAnalysis
    }

    if ($rawMetadata.scriptTemplateId -and -not [string]::IsNullOrWhiteSpace([string]$rawMetadata.scriptTemplateId)) {
        $createMetadata.scriptTemplateId = [string]$rawMetadata.scriptTemplateId
    }

    if ($rawMetadata.ContainsKey("detectionRules") -and $rawMetadata.detectionRules) {
        $createMetadata.detectionRules = $rawMetadata.detectionRules
    }

    if ($rawMetadata.logoData -and -not [string]::IsNullOrWhiteSpace([string]$rawMetadata.logoData)) {
        $createMetadata.logoData = [string]$rawMetadata.logoData
    }

    if ([string]::IsNullOrWhiteSpace([string]$createMetadata.name)) {
        throw "Package metadata is missing name/fullProductName/productName."
    }

    Write-Verbose "Creating remote package..."
    $createResponse = Invoke-RobopackPackageCreate -ApiKey $ApiKey -Metadata $createMetadata
    $packageId = [guid]$createResponse.id

    $finalizeFiles = @()
    $fileCount = $filesToUpload.Count
    $currentFileNumber = 0

    foreach ($file in $filesToUpload) {
        $currentFileNumber++
        $relativePath = [System.IO.Path]::GetRelativePath($contentPath, $file.FullName) -replace '\\','/'
        if ([string]::IsNullOrWhiteSpace($relativePath)) {
            throw "Failed to resolve relative content path for file: $($file.FullName)"
        }
        $finalizeFiles += @{
            fileName = $relativePath
            fileSize = [long]$file.Length
        }

        $totalBytes = [long]$file.Length
        $totalChunks = [int][Math]::Ceiling($totalBytes / [double]$chunkSizeBytes)
        $buffer = New-Object byte[] $chunkSizeBytes

        Write-Verbose "Uploading file $currentFileNumber/${fileCount}: $relativePath ($totalChunks chunk(s))..."

        $stream = [System.IO.File]::OpenRead($file.FullName)
        try {
            $offset = 0L
            $chunkIndex = 0

            while ($offset -lt $totalBytes) {
                $remaining = $totalBytes - $offset
                $toRead = [int][Math]::Min($chunkSizeBytes, $remaining)
                $read = $stream.Read($buffer, 0, $toRead)

                if ($read -le 0) {
                    throw "Unexpected end of file while reading chunk at offset $offset for file $relativePath."
                }

                $chunkBytes = New-Object byte[] $read
                [System.Array]::Copy($buffer, 0, $chunkBytes, 0, $read)

                $percent = [int](($chunkIndex + 1) * 100 / $totalChunks)
                Write-Progress `
                    -Activity "Uploading package content ($currentFileNumber/${fileCount}): $relativePath" `
                    -Status "Chunk $($chunkIndex + 1) of $totalChunks" `
                    -PercentComplete $percent

                try {
                    Invoke-RobopackPackageContentChunkUpload `
                        -ApiKey $ApiKey `
                        -PackageId $packageId `
                        -ChunkBytes $chunkBytes `
                        -Index ($currentFileNumber - 1) `
                        -Size $read `
                        -Offset $offset `
                        -Chunk $chunkIndex `
                        -MaxRetries $maxRetriesPerChunk | Out-Null
                }
                catch {
                    throw "Upload failed for package $packageId at file '$relativePath' (index $($currentFileNumber - 1)), chunk $chunkIndex of $totalChunks. Recommended next step: rerun Publish-RobopackPackage for this package. Details: $($_.Exception.Message)"
                }

                $offset += $read
                $chunkIndex++
            }
        }
        finally {
            $stream.Dispose()
            Write-Progress -Activity "Uploading package content ($currentFileNumber/${fileCount}): $relativePath" -Completed
        }
    }

    Write-Verbose "Finalizing upload..."
    $finalizeResponse = Invoke-RobopackPackageContentFinalize `
        -ApiKey $ApiKey `
        -PackageId $packageId `
        -Files $finalizeFiles

    Write-Verbose "Package published successfully."
    return [pscustomobject]@{
        PackageId    = $packageId
        FilesUploaded = $fileCount
        Created      = $createResponse
        Finalized    = $finalizeResponse
    }
}