Airpower.psm1

Add-Type -AssemblyName System.Net.Http

function HttpRequest {
    param (
        [Parameter(Mandatory)]
        [string]$URL,
        [ValidateSet('GET', 'HEAD')]
        [string]$Method = 'GET',
        [string]$AuthToken,
        [string]$Accept,
        [string]$Range
    )
    $req = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $URL)
    if ($AuthToken) {
        $req.Headers.Authorization = "Bearer $AuthToken"
    }
    if ($Accept) {
        $req.Headers.Accept.Add($Accept)
    }
    if ($Range) {
        $req.Headers.Range = $Range
    }
    return $req
}

function HttpSend {
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpRequestMessage]$Req,
        [switch]$NoRedirect
    )
    $ch = [Net.Http.HttpClientHandler]::new()
    if ($NoRedirect) {
        $ch.AllowAutoRedirect = $false
    }
    $ch.UseProxy = $false
    $cli = [Net.Http.HttpClient]::new($ch)
    $resp = $cli.SendAsync($Req, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
    return $resp
}

function GetJsonResponse {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    if (($resp.Content.Headers.ContentType.MediaType -ne 'application/json') -and -not $resp.Content.Headers.ContentType.MediaType.EndsWith('+json')) {
        throw "want application/json, got $($resp.Content.Headers.ContentType.MediaType)"
    }
    return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() | ConvertFrom-Json
}

function GetStringResponse {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function ConvertTo-HashTable {
    param (
        [Parameter(ValueFromPipeline)]
        [PSCustomObject]$Object
    )
    if ($null -eq $Object) {
        return
    }
    $Table = @{}
    $Object.PSObject.Properties | ForEach-Object {
        $V = $_.Value
        if ($V -is [Array]) {
            $V = [System.Collections.ArrayList]$V
        } elseif ($V -is [PSCustomObject]) {
            $V = ($V | ConvertTo-HashTable)
        }
        $Table.($_.Name) = $V
    }
    return $Table
}

function GetAirpowerPath {
    if ($AirpowerPath) {
        $AirpowerPath
    } elseif ($env:AirpowerPath) {
        $env:AirpowerPath
    } else {
        "$env:LocalAppData\Airpower"
    }
}

function GetAirpowerPullPolicy {
    if ($AirpowerPullPolicy) {
        $AirpowerPullPolicy
    } elseif ($env:AirpowerPullPolicy) {
        $env:AirpowerPullPolicy
    } else {
        "IfNotPresent"
    }
}

function GetAirpowerAutoprune {
    if ($AirpowerAutoprune) {
        $AirpowerAutoprune
    } elseif ($env:AirpowerAutoprune) {
        $env:AirpowerAutoprune
    }
}

function GetPwrDBPath {
    "$(GetAirpowerPath)\cache"
}

function GetPwrTempPath {
    "$(GetAirpowerPath)\temp"
}

function GetPwrContentPath {
    "$(GetAirpowerPath)\content"
}

function ResolvePackagePath {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    return "$(GetPwrContentPath)\$($digest.Substring('sha256:'.Length).Substring(0, 12))"
}

function MakeDirIfNotExist {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path
    )
    New-Item -Path $Path -ItemType Directory -ErrorAction Ignore
}

function FindConfig {
    $path = (Get-Location).Path
    while ($path -ne '') {
        $cfg = "$path\Airpower.ps1"
        if (Test-Path $cfg -PathType Leaf) {
            return $cfg
        }
        $path = $path | Split-Path -Parent
    }
}
# U+2588 ? Full block
# U+258C ? Left half block

function GetUnicodeBlock {
    param (
        [Parameter(Mandatory)]
        [int]$Index
    )
    @{
        0 = " "
        1 = "$([char]0x258c)"
        2 = "$([char]0x2588)"
    }[$Index]
}

function GetProgress {
    param (
        [Parameter(Mandatory)]
        [long]$Current,
        [Parameter(Mandatory)]
        [long]$Total
    )
    $width = 30
    $esc = [char]27
    $p = $Current / $Total
    $inc = 1 / $width
    $full = [int][Math]::Floor($p / $inc)
    $left = [int][Math]::Floor((($p - ($inc * $full)) / $inc) * 2)
    $line = "$esc[94m$esc[47m" + ((GetUnicodeBlock 2) * $full)
    if ($full -lt $width) {
        $line += (GetUnicodeBlock $left) + (" " * ($width - $full - 1))
    }
    $stat = '{0,10} / {1,-10}' -f ($Current | AsByteString -FixDecimals), ($Total | AsByteString)
    $line += "$esc[0m $stat"
    return "$line$esc[0m"
}

function WritePeriodicConsole {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [scriptblock]$DeferLine
    )
    if (($null -eq $lastwrite) -or (((Get-Date) - $lastwrite).TotalMilliseconds -gt 125)) {
        $line = & $DeferLine
        [Console]::Write("`r$line")
        $script:lastwrite = (Get-Date)
    }
}

function SetCursorVisible {
    param (
        [Parameter(Mandatory)]
        [bool]$Enable
    )
    try {
        [Console]::CursorVisible = $Enable
    } catch {
        Write-Error $_ -ErrorAction Ignore
    }
}

function WriteConsole {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Line
    )
    [Console]::Write("`r$Line")
}

function AsByteString {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [long]$Bytes,
        [switch]$FixDecimals
    )
    $n = [Math]::Abs($Bytes)
    $p = 0
    while ($n -gt 1024) {
        $n /= 1024
        $p += 3
    }
    $r = @{
        0 = ''
        3 = 'k'
        6 = 'M'
        9 = 'G'
    }
    return "{0:0.$(if ($FixDecimals) { '00' } else { '##' })} {1}B" -f $n, $r[[Math]::Min(9, $p)]
}

function FromOctalString {
    param (
        [Parameter(ValueFromPipeline)]
        [string]$ASCII
    )
    if (-not $ASCII) {
        return $null
    }
    return [Convert]::ToInt64($ASCII, 8)
}

function ParseTarHeader {
    param (
        [Parameter(Mandatory)]
        [byte[]]$Buffer
    )
    return @{
        Filename = [Text.Encoding]::ASCII.GetString($Buffer[0..99]).Trim(0)
        Mode = [Text.Encoding]::ASCII.GetString($Buffer[100..107]).Trim(0) | FromOctalString
        OwnerID = [Text.Encoding]::ASCII.GetString($Buffer[108..115]).Trim(0) | FromOctalString
        GroupID = [Text.Encoding]::ASCII.GetString($Buffer[116..123]).Trim(0) | FromOctalString
        Size = [Text.Encoding]::ASCII.GetString($Buffer[124..135]).Trim(0) | FromOctalString
        Modified = [Text.Encoding]::ASCII.GetString($Buffer[136..147]).Trim(0) | FromOctalString
        Checksum = [Text.Encoding]::ASCII.GetString($Buffer[148..155])
        Type = [Text.Encoding]::ASCII.GetString($Buffer[156..156]).Trim(0)
        Link = [Text.Encoding]::ASCII.GetString($Buffer[157..256]).Trim(0)
        UStar = [Text.Encoding]::ASCII.GetString($Buffer[257..262]).Trim(0)
        UStarVersion = [Text.Encoding]::ASCII.GetString($Buffer[263..264]).Trim(0)
        Owner = [Text.Encoding]::ASCII.GetString($Buffer[265..296]).Trim(0)
        Group = [Text.Encoding]::ASCII.GetString($Buffer[297..328]).Trim(0)
        DeviceMajor = [Text.Encoding]::ASCII.GetString($Buffer[329..336]).Trim(0)
        DeviceMinor = [Text.Encoding]::ASCII.GetString($Buffer[337..344]).Trim(0)
        FilenamePrefix = [Text.Encoding]::ASCII.GetString($Buffer[345..499]).Trim(0)
    }
}

function ParsePaxHeader {
    param (
        [Parameter(Mandatory)]
        [IO.Compression.GZipStream]$Source,
        [Parameter(Mandatory)]
        [Collections.Hashtable]$Header
    )
    $buf = New-Object byte[] $Header.Size
    [void]([Util]::GzipRead($Source, $buf, $Header.Size))
    $content = [Text.Encoding]::UTF8.GetString($buf)
    $xhdr = @{}
    foreach ($line in $content -split "`n") {
        if ($line -match '([0-9]+) ([^=]+)=(.+)') {
            $xhdr += @{
                "$($Matches[2])" = $Matches[3]
            }
        }
    }
    return $xhdr
}

function ExtractTarGz {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$Digest
    )
    $tgz = $Path | Split-Path -Leaf
    $layer = $tgz.Replace('.tar.gz', '')
    if ($layer -ne (Get-FileHash $Path).Hash) {
        [IO.File]::Delete($Path)
        throw "removed $Path because it had corrupted data"
    }
    $fs = [IO.File]::OpenRead($Path)
    try {
        $gz = [IO.Compression.GZipStream]::new($fs, [IO.Compression.CompressionMode]::Decompress, $true)
        try {
            $gz | ExtractTar -Digest $Digest
        } finally {
            $gz.Dispose()
        }
    } finally {
        $fs.Dispose()
    }
    return $Path
}

class Util {
    static [int] GzipRead([IO.Compression.GZipStream]$Source, [byte[]]$Buffer, [int]$Size) {
        $read = 0
        while ($true) {
            $n = $Source.Read($buffer, $read, $Size - $read)
            $read += $n
            if ($n -eq 0) {
                break
            } elseif ($read -ge $size) {
                break
            }
        }
        return $read
    }
}

function ExtractTar {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [IO.Compression.GZipStream]$Source,
        [Parameter(Mandatory)]
        [string]$Digest
    )
    $root = ResolvePackagePath -Digest $Digest
    MakeDirIfNotExist -Path $root | Out-Null
    $buffer = New-Object byte[] 512
    try {
        while ($true) {
            { $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Position -Total $Source.BaseStream.Length) + ' ' } | WritePeriodicConsole
            if ([Util]::GzipRead($Source, $buffer, 512) -eq 0) {
                break
            }
            $hdr = ParseTarHeader $buffer
            $size = if ($xhdr.Size) { $xhdr.Size } else { $hdr.Size }
            $filename = if ($xhdr.Path) { $xhdr.Path } else { $hdr.Filename }
            $file = ($filename -split '/' | Select-Object -Skip 1) -join '\'
            if ($filename.Contains('\..')) {
                throw "suspicious tar filename '$($filename)'"
            }
            if ($hdr.Type -eq [char]53 -and $file -ne '') {
                New-Item -Path "\\?\$root\$file" -ItemType Directory -Force -ErrorAction Ignore | Out-Null
            }
            if ($hdr.Type -in [char]103, [char]120) {
                $xhdr = ParsePaxHeader -Source $Source -Header $hdr
            } elseif ($hdr.Type -in [char]0, [char]48, [char]55 -and $filename.StartsWith('Files')) {
                $buf = New-Object byte[] $size
                [void]([Util]::GzipRead($Source, $buf, $size))
                $fs = [IO.File]::Open("\\?\$root\$file", [IO.FileMode]::Create, [IO.FileAccess]::Write)
                try {
                    if ($write) {
                        $write.Wait()
                        if ($write.IsFaulted) {
                            throw $write.Exception
                        }
                        $writefs.Dispose()
                    }
                } catch {
                    $fs.Dispose()
                    throw
                }
                $writefs = $fs
                $write = $writefs.WriteAsync($buf, 0, $size)
                $xhdr = $null
            } else {
                if ($size -gt 0) {
                    [void]([Util]::GzipRead($Source, (New-Object byte[] $size), $size))
                }
                $xhdr = $null
            }
            $leftover = $size % 512
            if ($leftover -gt 0) {
                [void]([Util]::GzipRead($Source, $buffer, (512 - $leftover)))
            }
        }
        if ($write) {
            $write.Wait()
            if ($write.IsFaulted) {
                throw $write.Exception
            }
        }
    } finally {
        if ($writefs) {
            $writefs.Dispose()
        }
    }
    $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Length -Total $Source.BaseStream.Length) + ' ' | WriteConsole
}

function GetDockerRepo {
    return 'airpower/shipyard'
}

function GetAuthToken {
    $auth = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$(GetDockerRepo):pull"
    $resp = HttpRequest $auth | HttpSend | GetJsonResponse
    return $resp.Token
}

function GetTagsList {
    $api = "/v2/$(GetDockerRepo)/tags/list"
    $endpoint = "https://index.docker.io$api"
    return HttpRequest $endpoint -AuthToken (GetAuthToken) | HttpSend | GetJsonResponse
}

function GetManifest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    $api = "/v2/$(GetDockerRepo)/manifests/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/vnd.docker.distribution.manifest.v2+json'
    }
    return HttpRequest @params | HttpSend
}

function GetBlob {
    param (
        [Parameter(Mandatory)]
        [string]$Ref,
        [long]$StartByte
    )
    $api = "/v2/$(GetDockerRepo)/blobs/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/octet-stream'
        Range = "bytes=$StartByte-$($StartByte + 536870911)" # Request in 512 MB chunks
    }
    return HttpRequest @params | HttpSend
}

function GetDigestForRef {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    $api = "/v2/$(GetDockerRepo)/manifests/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/vnd.docker.distribution.manifest.v2+json'
        Method = 'HEAD'
    }
    return HttpRequest @params | HttpSend | GetDigest
}

function GetDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $resp.Headers.GetValues('docker-content-digest')
}

function DebugRateLimit {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    if ($resp.Headers.Contains('ratelimit-limit')) {
        Write-Debug "DockerHub RateLimit = $($resp.Headers.GetValues('ratelimit-limit'))"
    }
    if ($resp.Headers.Contains('ratelimit-remaining')) {
        Write-Debug "DockerHub Remaining = $($resp.Headers.GetValues('ratelimit-remaining'))"
    }
}

function GetSize {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    $manifest = $Resp | GetJsonResponse
    $size = 0
    foreach ($layer in $manifest.layers) {
        if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') {
            $size += $layer.size
        }
    }
    return $size
}

function SaveBlob {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    $sha256 = $Digest.Substring('sha256:'.Length)
    $path = "$(GetPwrTempPath)\$sha256.tar.gz"
    if ((Test-Path $path) -and (Get-FileHash $path).Hash -eq $sha256) {
        return $path
    }
    MakeDirIfNotExist (GetPwrTempPath) | Out-Null
    $fs = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate)
    $fs.Seek(0, [IO.SeekOrigin]::End) | Out-Null
    try {
        do {
            $resp = GetBlob -Ref $Digest -StartByte $fs.Length
            if (-not $resp.IsSuccessStatusCode) {
                throw "cannot download blob $($Digest): $($resp.ReasonPhrase)"
            }
            $size = if ($resp.Content.Headers.ContentRange.HasLength) { $resp.Content.Headers.ContentRange.Length } else { $resp.Content.Headers.ContentLength + $fs.Length }
            $task = $resp.Content.CopyToAsync($fs)
            while (-not $task.IsCompleted) {
                $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole
                Start-Sleep -Milliseconds 125
            }
        } while ($fs.Length -lt $size)
        $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole
    } finally {
        $fs.Close()
    }
    return $path
}
function WriteHost {
    param (
        [string]$Line
    )
    Write-Information $Line -InformationAction Continue
}

class FileLock {
    hidden [IO.FileStream]$File
    hidden [IO.MemoryStream]$Buffer
    hidden [string]$Path
    hidden [IO.FileAccess]$Access
    hidden [bool]$Delete
    [string[]]$Key

    FileLock([string]$Path, [IO.FileAccess]$Access, [string[]]$Key) {
        $this.Key = $Key
        $this.Access = $Access
        $this.Path = $Path
        $this.File = [IO.FileStream]::new($Path, [IO.FileMode]::OpenOrCreate, $Access, [IO.FileShare]::Read)
        if ($Access -eq [IO.FileAccess]::ReadWrite) {
            $this.Buffer = [IO.MemoryStream]::new()
        }
    }

    static [FileLock] RLock([string]$Path, [string[]]$Key) {
        return [FileLock]::new($Path, [IO.FileAccess]::Read, $Key)
    }

    static [FileLock] Lock([string]$Path, [string[]]$Key) {
        return [FileLock]::new($Path, [IO.FileAccess]::ReadWrite, $Key)
    }

    Unlock() {
        if ($this.Buffer) {
            if ($this.Buffer.Length -gt 0) {
                $this.File.SetLength(0)
                $this.Buffer.WriteTo($this.File)
            }
        }
        $this.File.Dispose()
        if ($this.Delete) {
            [IO.File]::Delete($this.Path)
        }
    }

    Revert() {
        $this.File.Dispose()
    }

    Remove() {
        $this.Delete = $this.Access -eq [IO.FileAccess]::ReadWrite
    }

    [object] Get() {
        $b = [byte[]]::new($this.File.Length)
        $this.File.Read($b, 0, $this.File.Length)
        return [Db]::Decode([Text.Encoding]::UTF8.GetString($b))
    }

    Put([object]$Value) {
        $content = [Db]::Encode($value)
        $this.Buffer.Write([Text.Encoding]::UTF8.GetBytes($content), 0, [Text.Encoding]::UTF8.GetByteCount($content))
    }
}

class Db {
    static [string]$Dir = (GetPwrDBPath)

    static Db() {
        [Db]::Init()
    }

    static Init() {
        MakeDirIfNotExist ([Db]::Dir)
    }

    static Remove([string[]]$key) {
        [IO.File]::Delete("$([Db]::Dir)\$([Db]::Key($key))")
    }

    static [object] Get([string[]]$key) {
         return [Db]::Decode([IO.File]::ReadAllText("$([Db]::Dir)\$([Db]::Key($key))"))
    }

    static [object[]] TryGet([string[]]$key) {
        try {
            return [Db]::Get($key), $null
        } catch {
            return $null, $_
        }
    }

    static Put([string[]]$key, [object]$value) {
        [IO.File]::WriteAllText("$([Db]::Dir)\$([Db]::Key($key))", [Db]::Encode($value))
    }

    static [bool] TryPut([string[]]$key, [object]$value) {
        try {
            [Db]::TryPut($key, $value)
            return $true
        } catch {
            return $false
        }
    }

    static [string] Encode([object]$value) {
        $json = $value | ConvertTo-Json -Compress -Depth 10
        if ($null -eq $json) {
            $json = 'null' # PS 5.1 does not handle null
        }
        return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))
    }

    static [object] Decode([string]$value) {
        return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value)) | ConvertFrom-Json
    }

    static [FileLock] Lock([string[]]$key) {
        return [FileLock]::Lock("$([Db]::Dir)\$([Db]::Key($key))", $key)
    }

    static [object[]] TryLock([string[]]$key) {
        try {
            return [Db]::Lock($key), $null
        } catch {
            return $null, $_
        }
    }

    static [FileLock] RLock([string[]]$key) {
        return [FileLock]::RLock("$([Db]::Dir)\$([Db]::Key($key))", $key)
    }

    static [object[]] TryRLock([string[]]$key) {
        try {
            return [Db]::RLock($key), $null
        } catch {
            return $null, $_
        }
    }

    static [string] Key([string[]]$key) {
        $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($key -join "`n"))
        return $b64.Replace('/', '_').Replace('+', '-')
    }

    static [string[]] DecodeKey([string]$b64) {
        return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64.Replace('_', '/').Replace('-', '+'))) -split "`n"
    }

    static [bool] HasPrefix([string]$b64, [string[]]$key) {
        $s = [Db]::DecodeKey($b64)
        for($i=0; $i -lt $key.Length; $i+=1) {
            if ($key[$i] -ne $s[$i]) {
                return $false
            }
        }
        return $true
    }

    static [bool] ContainsKey([string[]]$key) {
        return [IO.File]::Exists("$([Db]::Dir)\$([Db]::Key($key))")
    }

    static [Entry[]] GetAll([string[]]$key) {
        $entries = @()
        foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) {
            $k = $f.Substring([Db]::Dir.Length+1)
            if ([Db]::HasPrefix($k, $key)) {
                $decodedKey = [Db]::DecodeKey($k)
                $v, $err = [Db]::TryGet($decodedKey)
                if (-not $err) {
                    $entries += @{
                        Key = $decodedKey
                        Value = $v
                    }
                }
            }
        }
        return $entries
    }

    static [object[]] TryLockAll([string[]]$key) {
        $locks = @()
        foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) {
            $k = $f.Substring([Db]::Dir.Length+1)
            if ([Db]::HasPrefix($k, $key)) {
                $decodedKey = [Db]::DecodeKey($k)
                try {
                    $locks += [Db]::Lock($decodedKey)
                } catch {
                    if ($locks) {
                        $locks.Revert()
                    }
                    return $null, "a package $($decodedKey) is being used by another airpower process"
                }
            }
        }
        return $locks, $null
    }
}

class Entry {
    [string[]]$Key
    [object]$Value
}


function AsRemotePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$RegistryTag
    )
    if ($RegistryTag -match '(.*)-([0-9].+)') {
        return @{
            Package = $Matches[1]
            Tag = $Matches[2] | AsTagHashtable
        }
    }
    throw "failed to parse registry tag: $RegistryTag"
}

function AsTagHashtable {
    param (
        [Parameter(ValueFromPipeline)]
        [string]$Tag
    )
    if ($Tag -in 'latest', '', $null) {
        return @{ Latest = $true }
    }
    if ($Tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') {
        return @{
            Major = $Matches[1]
            Minor = $Matches[2]
            Patch = $Matches[3]
            Build = $Matches[4]
        }
    }
    throw "failed to parse tag: $Tag"
}

function AsTagString {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [collections.Hashtable]$Tag
    )
    if ($true -eq $Tag.Latest) {
        "latest"
    } else {
        $s = "$($Tag.Major)"
        if ($Tag.Minor) {
            $s += ".$($Tag.Minor)"
        }
        if ($Tag.Patch) {
            $s += ".$($Tag.Patch)"
        }
        if ($Tag.Build) {
            $s += "+$($Tag.Build)"
        }
        $s
    }
}

function GetRemotePackages {
    $remote = @{}
    foreach ($tag in (GetTagsList).Tags) {
        $pkg = $tag | AsRemotePackage
        $remote.$($pkg.Package) = $remote.$($pkg.Package) + @($pkg.Tag)
    }
    $remote
}

function GetRemoteTags {
    $remote = GetRemotePackages
    $o = New-Object PSObject
    foreach ($k in $remote.keys | Sort-Object) {
        $arr = @()
        foreach ($t in $remote.$k) {
            $arr += [Tag]::new(($t | AsTagString))
        }
        $o | Add-Member -MemberType NoteProperty -Name $k -Value ($arr | Sort-Object -Descending)
    }
    $o
}

function AsPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Pkg
    )
    if ($Pkg -match '^([^:]+)(?::([^:]+))?(?:::?([^:]+))?$') {
        return @{
            Package = $Matches[1]
            Tag = $Matches[2] | AsTagHashtable
            Config = if ($Matches[3]) { $Matches[3] } else { 'default' }
        }
    }
    throw "failed to parse package: $Pkg"
}

function ResolvePackageRefPath {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    return "$(GetAirpowerPath)\ref\$($Pkg.Package)$(if (-not $Pkg.Tag.Latest) { "-$($Pkg.Tag | AsTagString)" })"
}

function ResolveRemoteRef {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $remote = GetRemoteTags
    if (-not $remote.$($Pkg.Package)) {
        throw "no such package: $($Pkg.Package)"
    }
    $want = $Pkg.Tag
    foreach ($got in $remote.$($Pkg.Package)) {
        $eq = $true
        if ($null -ne $want.Major) {
            $eq = $eq -and $want.Major -eq $got.Major
        }
        if ($null -ne $want.Minor) {
            $eq = $eq -and $want.Minor -eq $got.Minor
        }
        if ($null -ne $want.Patch) {
            $eq = $eq -and $want.Patch -eq $got.Patch
        }
        if ($null -ne $want.Build) {
            $eq = $eq -and $want.Build -eq $got.Build
        }
        if ($eq) {
            return "$($Pkg.Package)-$(($got.ToString()).Replace('+', '_'))"
        }
    }
    throw "no such $($Pkg.Package) tag: $($Pkg.Tag)"
}

function GetLocalPackages {
    $pkgs = @()
    $locks, $err = [Db]::TryLockAll('pkgdb')
    if ($err) {
        throw $err
    }
    foreach ($lock in $locks) {
        $tag = $lock.Key[2]
        $t = [Tag]::new($tag)
        $digest = if ($t.None) { $tag } else { $lock.Get() }
        $pkgs += [LocalPackage]@{
            Package = $lock.Key[1]
            Tag = $t
            Digest = $digest | AsDigest
            Size = [Db]::Get(('metadatadb', $digest)).size | AsSize

        }
        $lock.Unlock()
    }
    if (-not $pkgs) {
        $pkgs = ,[LocalPackage]@{}
    }
    return $pkgs
}

function ResolvePackageDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    if ($pkg.digest) {
        return $pkg.digest
    }
    $k = 'pkgdb', $Pkg.Package, ($Pkg.Tag | AsTagString)
    if ([Db]::ContainsKey($k)) {
        return [Db]::Get($k)
    }
}

function InstallPackage { # $locks, $status
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $digest = $Pkg.Digest
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    $locks = @()
    $mLock, $err = [Db]::TryLock(('metadatadb', $digest))
    if ($err) {
        throw "package '$digest' is in use by another airpower process"
    }
    $locks += $mLock
    $pLock, $err = [Db]::TryLock(('pkgdb', $name, $tag))
    if ($err) {
        $locks.Revert()
        throw "package '${name}:$tag' is in use by another airpower process"
    }
    $locks += $pLock
    $p = $pLock.Get()
    $m = $mLock.Get() | ConvertTo-HashTable
    $status = if ($null -eq $p) {
        if ($null -eq $m) {
            'new'
        } else {
            'tag'
        }
    } elseif ($digest -ne $p) {
        'newer'
    } else {
        'uptodate'
    }
    $pLock.Put($digest)
    switch ($status) {
        {$_ -in 'new', 'newer'} {
            $mLock.Put(@{
                RefCount = 1
                Size = $Pkg.Size
            })
        }
        'newer' {
            $moLock, $err = [Db]::TryLock(('metadatadb', $p))
            if ($err) {
                $locks.Revert()
                throw "package '$p' is in use by another airpower process"
            }
            $locks += $moLock
            $mo = $moLock.Get() | ConvertTo-HashTable
            $mo.RefCount -= 1
            if ($mo.RefCount -eq 0) {
                $poLock, $err = [Db]::TryLock(('pkgdb', $name, $p))
                if ($err) {
                    $locks.Revert()
                    throw "package '$p' is in use by another airpower process"
                }
                $locks += $poLock
                $poLock.Put($null)
                $mo.Orphaned = [DateTime]::UtcNow.ToString('u')
            }
            $moLock.Put($mo)
        }
        'tag' {
            if ([Db]::ContainsKey(('pkgdb', $name, $digest))) {
                $dLock, $err = [Db]::TryLock(('pkgdb', $name, $digest))
                if ($err) {
                    $locks.Revert()
                    throw "package '$digest' is in use by another airpower process"
                }
                $locks += $dLock
                $dLock.Remove()
            }
            if ($m.RefCount -eq 0 -and $m.Orphaned) {
                $m.Remove('Orphaned')
            }
            $m.RefCount += 1
            $mLock.Put($m)
        }
    }
    return $locks, $status
}

function PullPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $ref = $Pkg | ResolveRemoteRef
    $digest = $ref | GetDigestForRef
    WriteHost "Pulling $($Pkg.Package):$($pkg.Tag | AsTagString)"
    WriteHost "Digest: $($digest)"
    $k = 'metadatadb', $digest
    if ([Db]::ContainsKey($k)) {
        $m = [Db]::Get($k)
        $size = $m.Size
    } else {
        $manifest = $ref | GetManifest
        $manifest | DebugRateLimit
        $size = $manifest | GetSize
    }
    $Pkg.Digest = $digest
    $Pkg.Size = $size
    $locks, $status = $Pkg | InstallPackage
    $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
    if ($status -eq 'uptodate') {
        WriteHost "Status: Package is up to date for $ref"
    } else {
        if ($status -in 'new', 'newer') {
            $manifest | SavePackage
        }
        $refpath = $Pkg | ResolvePackageRefPath
        MakeDirIfNotExist (Split-Path $refpath) | Out-Null
        if (Test-Path -Path $refpath -PathType Container) {
            [IO.Directory]::Delete($refpath)
        }
        New-Item $refpath -ItemType Junction -Target ($Pkg.Digest | ResolvePackagePath) | Out-Null
        WriteHost "Status: Downloaded newer package for $ref"
    }
    $locks.Unlock()
}

function SavePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    SetCursorVisible $false
    try {
        $manifest = $Resp | GetJsonResponse
        $digest = $Resp | GetDigest
        $temp = @()
        foreach ($layer in $manifest.layers) {
            if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') {
                try {
                    $temp += $layer.Digest | SaveBlob | ExtractTarGz -Digest $digest
                    "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole
                } finally {
                    [Console]::WriteLine()
                }
            }
        }
        foreach ($tmp in $temp) {
            [IO.File]::Delete($tmp)
        }
    } finally {
        SetCursorVisible $true
    }
}

function UninstallPackage { # $locks, $digest, $err
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    $k = 'pkgdb', $name, $tag
    $locks = @()
    if (-not [Db]::ContainsKey($k)) {
        return $null, $null, "package '${name}:$tag' not installed"
    }
    $pLock, $err = [Db]::TryLock($k)
    if ($err) {
        return $null, $null, "package '${name}:$tag' is in use by another airpower process"
    }
    $locks += $pLock
    $p = $pLock.Get()
    $pLock.Remove()
    $mLock, $err = [Db]::TryLock(('metadatadb', $p))
    if ($err) {
        $locks.Revert()
        $null, $null, "package '$p' is in use by another airpower process"
    }
    $locks += $mLock
    $m = $mLock.Get()
    if ($m.refcount -gt 0) {
        $m.refcount -= 1
    }
    if ($m.refcount -eq 0) {
        $mLock.Remove()
        $digest = $p
    } else {
        $mLock.Put($m)
        $digest = $null
    }
    return $locks, $digest, $null
}

function RemovePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $locks, $digest, $err = $Pkg | UninstallPackage
    if ($null -ne $err) {
        throw $err
    }
    WriteHost "Untagged: $($Pkg.Package):$($pkg.Tag | AsTagString)"
    if ($null -ne $digest) {
        $content = $digest | ResolvePackagePath
        if (Test-Path $content -PathType Container) {
            [IO.Directory]::Delete($content, $true)
        }
        WriteHost "Deleted: $digest"
    }
    $refpath = $Pkg | ResolvePackageRefPath
    if (Test-Path -Path $refpath -PathType Container) {
        [IO.Directory]::Delete($refpath)
    }
    $locks.Unlock()
}

function UninstallOrphanedPackages {
    param (
        [timespan]$Span
    )
    $now = [datetime]::UtcNow
    $locks = @()
    $metadata = @()
    $ls, $err = [Db]::TryLockAll('metadatadb')
    if ($err) {
        throw $err
    }
    foreach ($lock in $ls) {
        $m = $lock.Get() | ConvertTo-HashTable
        if ($m.orphaned) {
            $orphaned = $now - [datetime]::Parse($m.orphaned)
        }
        if ($m.refcount -eq 0 -and $orphaned -ge $span) {
            $locks += $lock
            $m.digest = $lock.Key[1]
            $metadata += $m
            $lock.Remove()
        } else {
            $lock.Unlock()
        }
    }
    $ls, $err = [Db]::TryLockAll('pkgdb')
    if ($err) {
        if ($locks) {
            $locks.Revert()
        }
        throw $err
    }
    foreach ($lock in $ls) {
        if ($lock.Key[2] -match '^sha256:' -and $lock.Key[2] -in $metadata.digest) {
            $locks += $lock
            $lock.Remove()
        } else {
            $lock.Unlock()
        }
    }
    return $locks, $metadata
}

function PrunePackages {
    param (
        [switch]$Auto
    )
    if ($Auto -and -not (GetAirpowerAutoprune)) {
        return
    }
    $span = if ($Auto) { [timespan]::Parse((GetAirpowerAutoprune)) } else { [timespan]::new(0) }
    $locks, $pruned = UninstallOrphanedPackages $span
    $bytes = 0
    foreach ($i in $pruned) {
        $content = $i.Digest | ResolvePackagePath
        WriteHost "Deleted: $($i.Digest)"
        $stats = Get-ChildItem $content -Recurse | Measure-Object -Sum Length
        $bytes += $stats.Sum
        if (Test-Path $content -PathType Container) {
            [IO.Directory]::Delete("\\?\$((Resolve-Path $content).Path)", $true)
        }
    }
    if ($pruned) {
        WriteHost "Total reclaimed space: $($bytes | AsByteString)"
        $locks.Unlock()
    }
}

class Digest {
    [string]$Sha256

    Digest([string]$sha256) {
        $this.Sha256 = $sha256
    }

    [string] ToString() {
        return "$($this.Sha256.Substring('sha256:'.Length).Substring(0, 12))"
    }
}

function AsDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    return [Digest]::new($Digest)
}

class Tag : IComparable {
    [object]$Major
    [object]$Minor
    [object]$Patch
    [object]$Build
    hidden [bool]$None
    hidden [bool]$Latest

    Tag([string]$tag) {
        if ($tag -eq '<none>' -or $tag.StartsWith('sha256:')) {
            $this.None = $true
            return
        }
        if ($tag -in 'latest', '') {
            $this.Latest = $true
            return
        }
        if ($tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') {
            $this.Major = $Matches[1]
            $this.Minor = $Matches[2]
            $this.Patch = $Matches[3]
            $this.Build = $Matches[4]
            return
        }
        throw "failed to parse tag: $tag"
    }

    [int] CompareTo([object]$Obj) {
        if ($Obj -isnot $this.GetType()) {
            throw "cannot compare types $($Obj.GetType()) and $($this.GetType())"
        }
        if ($this.Latest -or $Obj.Latest) {
            return $this.Latest - $Obj.Latest
        }
        if ($this.None -or $Obj.None) {
            return $Obj.None - $this.None
        }
        if ($this.Major -ne $Obj.Major) {
            return $this.Major - $Obj.Major
        } elseif ($this.Minor -ne $Obj.Minor) {
            return $this.Minor - $Obj.Minor
        } elseif ($this.Patch -ne $Obj.Patch) {
            return $this.Patch - $Obj.Patch
        } else {
            return $this.Build - $Obj.Build
        }
    }

    [string] ToString() {
        if ($this.None) {
            return ''
        }
        if ($null -eq $this.Major) {
            return 'latest'
        }
        $s = "$($this.Major)"
        if ($this.Minor) {
            $s += ".$($this.Minor)"
        }
        if ($this.Patch) {
            $s += ".$($this.Patch)"
        }
        if ($this.Build) {
            $s += "+$($this.Build)"
        }
        return $s
    }
}

function ResolvePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    if ($Ref.StartsWith('file:///')) {
        return @{
            Digest = $Ref
            Tag = @{}
            Config = 'default'
        }
    }
    $pkg = $Ref | AsPackage
    $digest = $pkg | ResolvePackageDigest
    switch (GetAirpowerPullPolicy) {
        'IfNotPresent' {
            if (-not $digest) {
                $pkg | PullPackage | Out-Null
                $pkg.digest = $pkg | ResolvePackageDigest
            }
        }
        'Never' {
            if (-not $digest) {
                throw "cannot find package $($pkg.Package):$($pkg.Tag | AsTagString)"
            }
        }
        'Always' {
            $pkg | PullPackage | Out-Null
            $pkg.digest = $pkg | ResolvePackageDigest
        }
        default {
            throw "invalid AirpowerPullPolicy '$(GetAirpowerPullPolicy)'"
        }
    }
    return $pkg
}

class Size : IComparable {
    [long]$Bytes
    hidden [string]$ByteString

    Size([long]$Bytes, [string]$ByteString) {
        $this.Bytes = $Bytes
        $this.ByteString = $ByteString
    }

    [int] CompareTo([object]$Obj) {
        return $this.Bytes.CompareTo($Obj.Bytes)
    }

    [string] ToString() {
        return $this.ByteString
    }
}

function AsSize {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [long]$Bytes
    )
    return [Size]::new($Bytes, ($Bytes | AsByteString))
}

class LocalPackage {
    [object]$Package
    [Tag]$Tag
    [Digest]$Digest
    [Size]$Size
    [object]$Orphaned
    # Signers
}

function GetSessionState {
    return @{
        Vars = (Get-Variable -Scope Global | ForEach-Object { ConvertTo-HashTable $_ } )
        Env = (Get-Item Env:)
    }
}

function SaveSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    Set-Variable -Name "AirpowerSaveState_$GUID" -Value (GetSessionState) -Scope Global
}

function ClearSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    $default = "AirpowerSaveState_$GUID", '__LastHistoryId', '__VSCodeOriginalPrompt', '__VSCodeOriginalPSConsoleHostReadLine', '?', '^', '$', 'args', 'ConfirmPreference', 'DebugPreference', 'EnabledExperimentalFeatures', 'Error', 'ErrorActionPreference', 'ErrorView', 'ExecutionContext', 'false', 'FormatEnumerationLimit', 'HOME', 'Host', 'InformationPreference', 'input', 'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'MaximumHistoryCount', 'MyInvocation', 'NestedPromptLevel', 'null', 'OutputEncoding', 'PID', 'PROFILE', 'ProgressPreference', 'PSBoundParameters', 'PSCommandPath', 'PSCulture', 'PSDefaultParameterValues', 'PSEdition', 'PSEmailServer', 'PSHOME', 'PSScriptRoot', 'PSSessionApplicationName', 'PSSessionConfigurationName', 'PSSessionOption', 'PSStyle', 'PSUICulture', 'PSVersionTable', 'PWD', 'ShellId', 'StackTrace', 'true', 'VerbosePreference', 'WarningPreference', 'WhatIfPreference', 'ConsoleFileName', 'MaximumAliasCount', 'MaximumDriveCount', 'MaximumErrorCount', 'MaximumFunctionCount', 'MaximumVariableCount'
    foreach ($v in (Get-Variable -Scope Global)) {
        if ($v.name -notin $default) {
            Remove-Variable -Name $v.name -Scope Global -Force -ErrorAction SilentlyContinue
        }
    }
    foreach ($k in [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::User).keys) {
        if ($k -notin 'temp', 'tmp', 'AirpowerPath') {
            Remove-Item "env:$k" -Force -ErrorAction SilentlyContinue
        }
    }
    Remove-Item 'env:AirpowerLoadedPackages' -Force -ErrorAction SilentlyContinue
}

function RestoreSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    $state = (Get-Variable "AirpowerSaveState_$GUID").value
    foreach ($v in $state.vars) {
        Set-Variable -Name $v.name -Value $v.value -Scope Global -Force -ErrorAction SilentlyContinue
    }
    foreach ($e in $state.env) {
        Set-Item -Path "env:$($e.name)" -Value $e.value -Force -ErrorAction SilentlyContinue
    }
    Remove-Variable "AirpowerSaveState_$GUID" -Force -Scope Global -ErrorAction SilentlyContinue
}

function GetPackageDefinition {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    if (-not $Digest) {
        return $null
    }
    if ($digest.StartsWith('file:///')) {
        $root = $digest.Substring(8)
    } else {
        $root = ResolvePackagePath -Digest $Digest
    }
    return (Get-Content -Raw "$root\.pwr").Replace('${.}', $root.Replace('\', '\\')) | ConvertFrom-Json | ConvertTo-HashTable
}

function ConfigurePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg,
        [switch]$AppendPath
    )
    $defn = $Pkg.Digest | GetPackageDefinition
    $cfg = if ($Pkg.Config -eq 'default') { $defn } else { $defn.$($Pkg.Config) }
    if (-not $cfg) {
        throw "configuration '$($Pkg.Config)' not found for $($Pkg.Package):$($Pkg.Tag | AsTagString)"
    }
    foreach ($k in $cfg.env.keys) {
        if ($k -eq 'Path') {
            if ($AppendPath) {
                $pre = "$env:Path$(if ($env:Path -and -not $env:Path.EndsWith(';')) { ';' })"
            } else {
                $post = "$(if ($env:Path) { ';' })$env:Path"
            }
        } else {
            $pre = $post = ''
        }
        Set-Item "env:$k" "$pre$($cfg.env.$k)$post"
    }
}

function LoadPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $digest = $Pkg | ResolvePackageDigest
    $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
    if (-not $digest) {
        throw "no such package $ref"
    }
    $Pkg.Digest = $digest
    WriteHost "Digest: $digest"
    if ($digest -notin ($env:AirpowerLoadedPackages -split ';')) {
        $Pkg | ConfigurePackage
        $env:AirpowerLoadedPackages += "$(if ($env:AirpowerLoadedPackages) { ';' })$digest"
        WriteHost "Status: Session configured for $ref"
    } else {
        WriteHost "Status: Session is up to date for $ref"
    }
}

function ExecuteScript {
    param (
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,
        [Parameter(Mandatory)]
        [Collections.Hashtable[]]$Pkgs
    )
    $GUID = New-Guid
    SaveSessionState $GUID
    try {
        ClearSessionState $GUID
        $env:Path = ''
        foreach ($pkg in $Pkgs) {
            $pkg.digest = $pkg | ResolvePackageDigest
            $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
            if (-not $pkg.digest) {
                throw "no such package $ref"
            }
            $pkg | ConfigurePackage -AppendPath
        }
        $env:Path = "$(if ($env:Path) { "$env:Path;" })$env:SYSTEMROOT;$env:SYSTEMROOT\System32;$PSHOME"
        & $ScriptBlock
    } finally {
        RestoreSessionState $GUID
    }
}

function Invoke-Airpower {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('version', 'v', 'remote', 'list', 'load', 'pull', 'exec', 'run', 'remove', 'rm', 'prune', 'help', 'h')]
        [string]$Command,
        [Parameter(ValueFromRemainingArguments)]
        [object[]]$ArgumentList
    )
    try {
        switch ($Command) {
            {$_ -in 'v', 'version'} {
                Invoke-AirpowerVersion
            }
            'remote' {
                Invoke-AirpowerRemote @ArgumentList
            }
            'list' {
                Invoke-AirpowerList
            }
            'load' {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerLoad @ArgumentList
                } else {
                    Invoke-AirpowerLoad $ArgumentList
                }
            }
            'pull' {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerPull @ArgumentList
                } else {
                    Invoke-AirpowerPull $ArgumentList
                }
            }
            'prune' {
                Invoke-AirpowerPrune
            }
            {$_ -in 'remove', 'rm'} {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerRemove @ArgumentList
                } else {
                    Invoke-AirpowerRemove $ArgumentList
                }
            }
            'exec' {
                $params, $remaining = ResolveParameters 'Invoke-AirpowerExec' $ArgumentList
                if ((-not $params.ScriptBlock) -and ($null -ne $remaining) -and ($remaining[-1] -isnot [scriptblock])) {
                    $params.Packages += $remaining | ForEach-Object { $_ }
                    $remaining = @()
                }
                Invoke-AirpowerExec @params @remaining
            }
            'run' {
                Invoke-AirpowerRun @ArgumentList
            }
            {$_ -in 'help', 'h'} {
                Invoke-AirpowerHelp
            }
        }
    } catch {
        Write-Error $_
    }
}

function GetConfigPackages {
    $cfg = FindConfig
    if ($cfg) {
        . $cfg
    }
    [string[]]$AirpowerPackages
}

function ResolveParameters {
    param (
        [Parameter(Mandatory)]
        [string]$FnName,
        [object[]]$ArgumentList
    )
    $fn = Get-Item "function:$FnName"
    $params = @{}
    $remaining = [Collections.ArrayList]@()
    for ($i = 0; $i -lt $ArgumentList.Count; $i++) {
        if ($fn.parameters.keys -and ($ArgumentList[$i] -match '^-([^:]+)(?::(.*))?$') -and ($Matches[1] -in $fn.parameters.keys)) {
            $name = $Matches[1]
            $value = $Matches[2]
            if ($value) {
                $params.$name = $value
            } else {
                if ($fn.parameters.$name.SwitchParameter -and $null -eq $value) {
                    $params.$name = $true
                } else {
                    $params.$name = $ArgumentList[$i+1]
                    $i++
                }
            }
        } else {
            [void]$remaining.Add($ArgumentList[$i])
        }
    }
    return $params, $remaining
}

function Invoke-AirpowerVersion {
    [CmdletBinding()]
    param ()
    (Get-Module -Name Airpower).Version
}

function Invoke-AirpowerList {
    [CmdletBinding()]
    param ()
    GetLocalPackages
}

function Invoke-AirpowerLoad {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error 'no packages provided'
    }
    foreach ($p in $Packages) {
        $p | ResolvePackage | LoadPackage
    }
}

function Invoke-AirpowerRemove {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    foreach ($p in $Packages) {
        $p | AsPackage | RemovePackage
    }
}

function Invoke-AirpowerPrune {
    [CmdletBinding()]
    param ()
    PrunePackages
}

function Invoke-AirpowerPull {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error "no packages provided"
    }
    foreach ($p in $Packages) {
        $p | AsPackage | PullPackage
    }
}

function Invoke-AirpowerRun {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$FnName,
        [Parameter(ValueFromRemainingArguments)]
        [object[]]$ArgumentList
    )
    $cfg = FindConfig
    if ($cfg) {
        . $cfg
    }
    $fn = Get-Item "function:Airpower$FnName"
    if ($fn) {
        $params, $remaining = ResolveParameters "Airpower$FnName" $ArgumentList
        $script = { & $fn @params @remaining }
        if ($AirpowerPackages) {
            Invoke-AirpowerExec -Packages $AirpowerPackages -ScriptBlock $script
        } else {
            & $script
        }
    }
}

function Invoke-AirpowerExec {
    [CmdletBinding()]
    param (
        [string[]]$Packages,
        [scriptblock]$ScriptBlock = { $Host.EnterNestedPrompt() }
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error "no packages provided"
    }
    $resolved = @()
    foreach ($p in $Packages) {
        $resolved += $p | ResolvePackage
    }
    ExecuteScript -ScriptBlock $ScriptBlock -Pkgs $resolved
}

function Invoke-AirpowerRemote {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('list')]
        [string]$Command
    )
    switch ($Command) {
        'list' {
            GetRemoteTags
        }
    }
}

function Invoke-AirpowerHelp {
@"
 
Usage: airpower COMMAND
 
A package manager and environment to provide consistent tooling for software teams
 
Commands:
    version, v Outputs the version of the module
    list Outputs a list of installed packages
    remote list Outputs an object of remote packages and versions
    pull Downlaods packages
    load Loads packages into the PowerShell session
    exec Runs a user-defined scriptblock in a managed PowerShell session state
    run Runs a user-defined scriptblock provided in a project file
    prune Deletes unreferenced packages
    remove, rm Untags and deletes packages
"@

}

Set-Alias -Name 'airpower' -Value 'Invoke-Airpower' -Scope Global
Set-Alias -Name 'air' -Value 'Invoke-Airpower' -Scope Global
Set-Alias -Name 'pwr' -Value 'Invoke-Airpower' -Scope Global

& {
    if ('Airpower.psm1' -eq (Split-Path $MyInvocation.ScriptName -Leaf)) {
        # Invoked as a module
        $params = @{
            URL = "https://www.powershellgallery.com/packages/airpower"
            Method = 'HEAD'
        }
        $resp = HttpRequest @params | HttpSend -NoRedirect
        if ($resp.Headers.Location) {
            $remote = [Version]::new($resp.Headers.Location.OriginalString.Substring('/packages/airpower/'.Length))
            $local = [Version]::new((Import-PowerShellDataFile -Path "$PSScriptRoot\Airpower.psd1").ModuleVersion)
            if ($remote -gt $local) {
                WriteHost "$([char]27)[92mA new version of Airpower is available! [v$remote]$([char]27)[0m"
            }
        }
        PrunePackages -Auto
    }
}