Public/Start-WingetServer.ps1

function Start-WingetServer {
    <#
    .SYNOPSIS
        Start a REST API server for remote winget package management.
 
    .DESCRIPTION
        Launches a Pode-based HTTP API server that exposes WingetBatch operations
        as RESTful endpoints. Enables remote package management, monitoring, and
        automation from any HTTP client, CI/CD pipeline, or management dashboard.
 
        Endpoints include package listing, search, install, update, uninstall,
        machine state management, and system health monitoring. All responses
        are JSON. Optional API key authentication secures the endpoints.
 
    .PARAMETER Port
        TCP port to listen on. Default: 8484.
 
    .PARAMETER ApiKey
        API key for authentication. If not specified, generates a random key
        and displays it on startup. Pass 'none' to disable authentication.
 
    .PARAMETER Hostname
        Hostname/IP to bind to. Default: localhost. Use '0.0.0.0' for all interfaces.
 
    .PARAMETER Https
        Enable HTTPS with a self-signed certificate.
 
    .PARAMETER OpenBrowser
        Open the API documentation page in the default browser on startup.
 
    .PARAMETER MaxRequestsPerMinute
        Rate limit per client IP. Default: 60. Set 0 to disable.
 
    .PARAMETER LogRequests
        Log all API requests to a file in the config directory.
 
    .EXAMPLE
        Start-WingetServer
        Starts the API on localhost:8484 with a generated API key.
 
    .EXAMPLE
        Start-WingetServer -Port 9000 -ApiKey "my-secret-key" -Hostname 0.0.0.0
        Starts on all interfaces, port 9000, with a custom API key.
 
    .EXAMPLE
        Start-WingetServer -ApiKey none -OpenBrowser
        Starts without authentication and opens docs in browser.
 
    .NOTES
        Author: Matthew Bubb
        Requires: Pode module (auto-installed if missing).
        API Docs: http://localhost:8484/ (when running)
    #>

    [CmdletBinding()]
    param(
        [ValidateRange(1024, 65535)]
        [int]$Port = 8484,

        [string]$ApiKey,

        [string]$Hostname = 'localhost',

        [switch]$Https,

        [switch]$OpenBrowser,

        [ValidateRange(0, 10000)]
        [int]$MaxRequestsPerMinute = 60,

        [switch]$LogRequests
    )

    # Ensure Pode is available
    if (-not (Get-Module -ListAvailable -Name Pode)) {
        Write-Host " Installing Pode module (REST API framework)..." -ForegroundColor Cyan
        Install-Module -Name Pode -Force -Scope CurrentUser -SkipPublisherCheck
    }
    Import-Module Pode -Force

    # Generate or validate API key
    if ([string]::IsNullOrEmpty($ApiKey)) {
        $ApiKey = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
        Write-Host " Generated API Key: " -NoNewline -ForegroundColor DarkGray
        Write-Host $ApiKey -ForegroundColor Yellow
        Write-Host " (Use header: X-API-Key: $ApiKey)" -ForegroundColor DarkGray
    }
    $disableAuth = ($ApiKey -eq 'none')

    # Config
    $configDir = Get-WingetBatchConfigDir
    $logFile = if ($LogRequests) { Join-Path $configDir "server_requests.log" } else { $null }
    $protocol = if ($Https) { "https" } else { "http" }
    $baseUrl = "${protocol}://${Hostname}:$Port"

    # Banner
    Write-Host ""
    Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host " ║ WingetBatch API Server ║" -ForegroundColor Cyan
    Write-Host " ║ Remote Package Management ║" -ForegroundColor Cyan
    Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host ""
    Write-Host " URL: " -NoNewline -ForegroundColor DarkGray; Write-Host $baseUrl -ForegroundColor White
    Write-Host " Docs: " -NoNewline -ForegroundColor DarkGray; Write-Host "$baseUrl/" -ForegroundColor White
    Write-Host " Auth: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($disableAuth) { "Disabled" } else { "API Key (X-API-Key header)" }) -ForegroundColor White
    Write-Host " Rate: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($MaxRequestsPerMinute -gt 0) { "$MaxRequestsPerMinute req/min" } else { "Unlimited" }) -ForegroundColor White
    Write-Host " Logging: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($LogRequests) { $logFile } else { "Off" }) -ForegroundColor White
    Write-Host ""
    Write-Host " Press Ctrl+C to stop the server." -ForegroundColor DarkGray
    Write-Host ""

    if ($OpenBrowser) {
        Start-Process $baseUrl
    }

    # Start Pode server
    Start-PodeServer -Threads 4 {
        # Listener
        Add-PodeEndpoint -Address $using:Hostname -Port $using:Port -Protocol $(if ($using:Https) { 'Https' } else { 'Http' })

        # Middleware: API Key auth
        if (-not $using:disableAuth) {
            Add-PodeRouteMiddleware -Name 'ApiAuth' -ScriptBlock {
                $apiKey = $WebEvent.Headers['X-API-Key']
                if ($apiKey -ne $using:ApiKey) {
                    Set-PodeResponseStatus -Code 401
                    Write-PodeJsonResponse -Value @{ error = "Unauthorized"; message = "Invalid or missing X-API-Key header" }
                    return $false
                }
                return $true
            }
        }

        # Middleware: Rate limiting
        if ($using:MaxRequestsPerMinute -gt 0) {
            Add-PodeRouteMiddleware -Name 'RateLimit' -ScriptBlock {
                $clientIp = $WebEvent.Request.RemoteEndPoint.Address.IPAddressToString
                $key = "rate_$clientIp"
                $now = Get-Date
                $window = $now.AddMinutes(-1)

                if (-not $script:RateTable) { $script:RateTable = @{} }
                if (-not $script:RateTable.ContainsKey($key)) { $script:RateTable[$key] = [System.Collections.Generic.List[datetime]]::new() }

                $script:RateTable[$key].RemoveAll({ param($t) $t -lt $window })
                if ($script:RateTable[$key].Count -ge $using:MaxRequestsPerMinute) {
                    Set-PodeResponseStatus -Code 429
                    Write-PodeJsonResponse -Value @{ error = "Rate limit exceeded"; retry_after_seconds = 60 }
                    return $false
                }
                $script:RateTable[$key].Add($now)
                return $true
            }
        }

        # Middleware: Request logging
        if ($using:logFile) {
            Add-PodeRouteMiddleware -Name 'RequestLog' -ScriptBlock {
                $entry = "$(Get-Date -Format 'o') | $($WebEvent.Request.HttpMethod) $($WebEvent.Request.Url.PathAndQuery) | $($WebEvent.Request.RemoteEndPoint.Address)"
                Add-Content -Path $using:logFile -Value $entry
                return $true
            }
        }

        # --- ROUTES ---

        # GET / - API documentation
        Add-PodeRoute -Method Get -Path '/' -ScriptBlock {
            Write-PodeJsonResponse -Value @{
                name = "WingetBatch API"
                version = "2.8.0"
                description = "REST API for remote winget package management"
                endpoints = @(
                    @{ method = "GET"; path = "/api/health"; description = "Server health check" }
                    @{ method = "GET"; path = "/api/packages"; description = "List installed packages" }
                    @{ method = "GET"; path = "/api/packages/:id"; description = "Get package details" }
                    @{ method = "GET"; path = "/api/search?q=query"; description = "Search winget source" }
                    @{ method = "POST"; path = "/api/packages/install"; description = "Install package(s)" }
                    @{ method = "POST"; path = "/api/packages/uninstall"; description = "Uninstall package(s)" }
                    @{ method = "GET"; path = "/api/updates"; description = "List available updates" }
                    @{ method = "POST"; path = "/api/updates/apply"; description = "Apply all updates" }
                    @{ method = "GET"; path = "/api/state"; description = "Get machine state" }
                    @{ method = "POST"; path = "/api/state/export"; description = "Export machine state" }
                    @{ method = "GET"; path = "/api/history"; description = "Installation history" }
                    @{ method = "GET"; path = "/api/stats"; description = "Package statistics" }
                )
                auth = if ($using:disableAuth) { "none" } else { "X-API-Key header" }
            }
        }

        # GET /api/health
        Add-PodeRoute -Method Get -Path '/api/health' -ScriptBlock {
            $wingetOk = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
            $comOk = $null -ne (Get-Module -ListAvailable Microsoft.WinGet.Client)
            Write-PodeJsonResponse -Value @{
                status = "healthy"
                timestamp = (Get-Date -ToString 'o')
                hostname = $env:COMPUTERNAME
                winget_cli = $wingetOk
                winget_com = $comOk
                module_version = (Get-Module WingetBatch).Version.ToString()
                uptime_seconds = ((Get-Date) - $PodeServer.StartTime).TotalSeconds
            }
        }

        # GET /api/packages
        Add-PodeRoute -Method Get -Path '/api/packages' -ScriptBlock {
            $source = $QueryData.source
            $packages = Microsoft.WinGet.Client\Get-WinGetPackage
            if ($source) { $packages = $packages | Where-Object { $_.Source -eq $source } }
            Write-PodeJsonResponse -Value @{
                count = $packages.Count
                packages = @($packages | ForEach-Object {
                    @{ id = $_.Id; name = $_.Name; version = $_.InstalledVersion; source = $_.Source; available = $_.AvailableVersions }
                })
            }
        }

        # GET /api/packages/:id
        Add-PodeRoute -Method Get -Path '/api/packages/:id' -ScriptBlock {
            $id = $RouteParameters.id
            $pkg = Microsoft.WinGet.Client\Get-WinGetPackage -Id $id
            if (-not $pkg) {
                Set-PodeResponseStatus -Code 404
                Write-PodeJsonResponse -Value @{ error = "Not found"; message = "Package '$id' is not installed" }
                return
            }
            Write-PodeJsonResponse -Value @{
                id = $pkg.Id; name = $pkg.Name; installed_version = $pkg.InstalledVersion
                available_versions = $pkg.AvailableVersions; source = $pkg.Source
                update_available = ($pkg.AvailableVersions -and $pkg.AvailableVersions[0] -ne $pkg.InstalledVersion)
            }
        }

        # GET /api/search
        Add-PodeRoute -Method Get -Path '/api/search' -ScriptBlock {
            $query = $QueryData.q
            if (-not $query) {
                Set-PodeResponseStatus -Code 400
                Write-PodeJsonResponse -Value @{ error = "Bad request"; message = "Query parameter 'q' is required" }
                return
            }
            $limit = [int]($QueryData.limit ?? 25)
            $results = Microsoft.WinGet.Client\Find-WinGetPackage -Query $query -Count $limit
            Write-PodeJsonResponse -Value @{
                query = $query; count = $results.Count
                results = @($results | ForEach-Object {
                    @{ id = $_.Id; name = $_.Name; version = $_.Version; source = $_.Source; publisher = $_.Publisher }
                })
            }
        }

        # POST /api/packages/install
        Add-PodeRoute -Method Post -Path '/api/packages/install' -ScriptBlock {
            $body = $WebEvent.Data
            $ids = $body.packages ?? @($body.id)
            if (-not $ids -or $ids.Count -eq 0) {
                Set-PodeResponseStatus -Code 400
                Write-PodeJsonResponse -Value @{ error = "Bad request"; message = "Provide 'packages' array or 'id' field" }
                return
            }
            $results = @()
            foreach ($id in $ids) {
                try {
                    Microsoft.WinGet.Client\Install-WinGetPackage -Id $id -Mode Silent | Out-Null
                    $results += @{ id = $id; status = "installed" }
                } catch {
                    $results += @{ id = $id; status = "failed"; error = $_.Exception.Message }
                }
            }
            Write-PodeJsonResponse -Value @{ installed = ($results | Where-Object status -eq 'installed').Count; failed = ($results | Where-Object status -eq 'failed').Count; results = $results }
        }

        # POST /api/packages/uninstall
        Add-PodeRoute -Method Post -Path '/api/packages/uninstall' -ScriptBlock {
            $body = $WebEvent.Data
            $ids = $body.packages ?? @($body.id)
            if (-not $ids -or $ids.Count -eq 0) {
                Set-PodeResponseStatus -Code 400
                Write-PodeJsonResponse -Value @{ error = "Bad request"; message = "Provide 'packages' array or 'id' field" }
                return
            }
            $results = @()
            foreach ($id in $ids) {
                try {
                    Microsoft.WinGet.Client\Uninstall-WinGetPackage -Id $id -Mode Silent | Out-Null
                    $results += @{ id = $id; status = "uninstalled" }
                } catch {
                    $results += @{ id = $id; status = "failed"; error = $_.Exception.Message }
                }
            }
            Write-PodeJsonResponse -Value @{ uninstalled = ($results | Where-Object status -eq 'uninstalled').Count; failed = ($results | Where-Object status -eq 'failed').Count; results = $results }
        }

        # GET /api/updates
        Add-PodeRoute -Method Get -Path '/api/updates' -ScriptBlock {
            $updates = Get-WingetUpdates
            Write-PodeJsonResponse -Value @{
                count = ($updates ?? @()).Count
                updates = @($updates | ForEach-Object {
                    @{ id = $_.Id; name = $_.Name; installed = $_.InstalledVersion; available = $_.AvailableVersion; source = $_.Source }
                })
            }
        }

        # POST /api/updates/apply
        Add-PodeRoute -Method Post -Path '/api/updates/apply' -ScriptBlock {
            $body = $WebEvent.Data
            $ids = $body.packages  # Optional: specific packages, else all
            $updates = Get-WingetUpdates
            if ($ids) { $updates = $updates | Where-Object { $_.Id -in $ids } }

            $results = @()
            foreach ($pkg in $updates) {
                try {
                    Microsoft.WinGet.Client\Update-WinGetPackage -Id $pkg.Id -Mode Silent | Out-Null
                    $results += @{ id = $pkg.Id; status = "updated"; version = $pkg.AvailableVersion }
                } catch {
                    $results += @{ id = $pkg.Id; status = "failed"; error = $_.Exception.Message }
                }
            }
            Write-PodeJsonResponse -Value @{ updated = ($results | Where-Object status -eq 'updated').Count; failed = ($results | Where-Object status -eq 'failed').Count; results = $results }
        }

        # GET /api/state
        Add-PodeRoute -Method Get -Path '/api/state' -ScriptBlock {
            $packages = Microsoft.WinGet.Client\Get-WinGetPackage
            Write-PodeJsonResponse -Value @{
                hostname = $env:COMPUTERNAME
                timestamp = (Get-Date -ToString 'o')
                total_packages = $packages.Count
                by_source = ($packages | Group-Object Source | ForEach-Object { @{ source = $_.Name; count = $_.Count } })
                packages = @($packages | ForEach-Object { @{ id = $_.Id; name = $_.Name; version = $_.InstalledVersion } })
            }
        }

        # POST /api/state/export
        Add-PodeRoute -Method Post -Path '/api/state/export' -ScriptBlock {
            $body = $WebEvent.Data
            $format = $body.format ?? 'json'
            $tempPath = Join-Path $env:TEMP "wingetbatch_state_$(Get-Random).$format"
            try {
                Get-WingetMachineState -Export -Path $tempPath -Format $format
                $content = Get-Content $tempPath -Raw
                Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
                Write-PodeJsonResponse -Value @{ status = "exported"; format = $format; content = ($content | ConvertFrom-Json) }
            } catch {
                Set-PodeResponseStatus -Code 500
                Write-PodeJsonResponse -Value @{ error = "Export failed"; message = $_.Exception.Message }
            }
        }

        # GET /api/history
        Add-PodeRoute -Method Get -Path '/api/history' -ScriptBlock {
            $days = [int]($QueryData.days ?? 30)
            $history = Get-WingetHistory -Days $days
            Write-PodeJsonResponse -Value @{
                days = $days; count = $history.Count
                entries = @($history | ForEach-Object {
                    @{ name = $_.Name; publisher = $_.Publisher; version = $_.Version; date = $_.InstallDate; action = $_.Action }
                })
            }
        }

        # GET /api/stats
        Add-PodeRoute -Method Get -Path '/api/stats' -ScriptBlock {
            $packages = Microsoft.WinGet.Client\Get-WinGetPackage
            $updates = Get-WingetUpdates
            Write-PodeJsonResponse -Value @{
                total_installed = $packages.Count
                updates_available = ($updates ?? @()).Count
                sources = @($packages | Group-Object Source | ForEach-Object { @{ name = $_.Name; count = $_.Count } })
                top_publishers = @($packages | Group-Object { ($_.Id -split '\.')[0] } | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ publisher = $_.Name; count = $_.Count } })
            }
        }
    }
}