Airpower.psm1

Add-Type -AssemblyName System.Net.Http

function HttpRequest {
    param (
        [Parameter(Mandatory = $true)]
        [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 = $true,
            ValueFromPipeline = $true)]
        [Net.Http.HttpRequestMessage]$Req
    )
    $ch = [Net.Http.HttpClientHandler]::new()
    $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 = $true,
            ValueFromPipeline = $true)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function ConvertTo-HashTable {
    param (
        [Parameter(ValueFromPipeline)]
        [PSCustomObject]$Object
    )
    $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 GetPwrHome {
    if ($PwrHome) {
        $PwrHome
    } elseif ($env:PwrHome) {
        $env:PwrHome
    } else {
        "$env:LocalAppData\pwr"
    }
}

function GetPwrPullPolicy {
    if ($PwrPullPolicy) {
        $PwrPullPolicy
    } elseif ($env:PwrPullPolicy) {
        $env:PwrPullPolicy
    } else {
        "IfNotPresent"
    }
}

function GetPwrDBPath {
    "$(GetPwrHome)\pwrdb"
}

function GetPwrTempPath {
    "$(GetPwrHome)\tmp"
}

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

function MakeDirIfNotExist {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path
    )
    if (-not (Test-Path $Path -PathType Container)) {
        New-Item -Path $Path -ItemType Directory
    }
}

function OutPwrDB {
    param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true)]
        [Collections.Hashtable]$PwrDB
    )
    MakeDirIfNotExist (GetPwrHome)
    $PwrDB |
        ConvertTo-Json -Compress -Depth 10 |
        Out-File -FilePath (GetPwrDBPath) -Encoding 'UTF8' -Force
}

function GetPwrDB {
    $db = GetPwrDBPath
    if (Test-Path $db -PathType Leaf) {
        Get-Content $db -Raw | ConvertFrom-Json | ConvertTo-HashTable
    } else {
        @{
            'pkgdb' = @{}
            'metadatadb' = @{}
        }
    }
}

function FindConfig {
    $path = Resolve-Path .
    while ($path -ne '') {
        $cfg = "$path\pwr.ps1"
        if (Test-Path $cfg -PathType Leaf) {
            return $cfg
        }
        $path = $path | Split-Path -Parent
    }
}
# U+2588 ? Full block
# U+2589 ? Left seven eighths block
# U+258A ? Left three quarters block
# U+258B ? Left five eighths block
# U+258C ? Left half block
# U+258D ? Left three eighths block
# U+258E ? Left one quarter block
# U+258F ? Left one eighth block

function GetUnicodeBlock {
    param (
        [Parameter(Mandatory = $true)]
        [int]$Index
    )
    @{
        0 = " "
        1 = "$([char]0x258f)"
        2 = "$([char]0x258e)"
        3 = "$([char]0x258d)"
        4 = "$([char]0x258c)"
        5 = "$([char]0x258b)"
        6 = "$([char]0x258a)"
        7 = "$([char]0x2589)"
        8 = "$([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) * 8)
    $line = "$esc[94m$esc[47m" + ((GetUnicodeBlock 8) * $full)
    if ($full -ne $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)]
        [string]$Line
    )
    if (($null -eq $lastwrite) -or (((get-date) - $lastwrite) -gt 125)) {
        [Console]::Write("`r$Line")
        $script:lastwrite = (get-date)
    }
}

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)]
}

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 = $true,
            ValueFromPipeline = $true)]
        [long]$Bytes
    )
    return [Size]::new($Bytes, ($Bytes | AsByteString))
}

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.Stream]$Source,
        [Parameter(Mandatory)]
        [Collections.Hashtable]$Header
    )
    $buf = New-Object byte[] $Header.Size
    $Source.Read($buf, 0, $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 CopyToFile {
    param (
        [Parameter(Mandatory)]
        [IO.Stream]$Source,
        [Parameter(Mandatory)]
        [string]$FilePath,
        [Parameter(Mandatory)]
        [long]$Size,
        [string]$Digest
    )
    try {
        $copied = 0
        $bufsize = 4096
        $buf = New-Object byte[] $bufsize
        $fs = [IO.File]::Open("\\?\$FilePath", [IO.FileMode]::Create)
        $fs.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
        $Digest.Substring(0,12) + ': Extracting ' + (GetProgress -Current $Stream.Position -Total $Stream.Length) + ' ' | WritePeriodicConsole
        while ($copied -lt $Size) {
            $amount = if (($Size - $copied) -gt $bufsize) { $bufsize } else { $Size - $copied }
            $Source.Read($buf, 0, $amount) | Out-Null
            $fs.Write($buf, 0, $amount) | Out-Null
            $copied += $amount
        }
    } finally {
        $fs.Dispose()
    }
}

function DecompressTarGz {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path
    )
    $tgz = $Path | Split-Path -Leaf
    $layer = $tgz.Replace('.tar.gz', '')
    $tar = $Path.Replace('.tar.gz', '.tar')
    try {
        $stream = [IO.File]::Open($tar, [IO.FileMode]::OpenOrCreate)
        $stream.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
        $fs = [IO.File]::Open($Path, [IO.FileMode]::Open)
        $gz = [IO.Compression.GZipStream]::new($fs, [IO.Compression.CompressionMode]::Decompress)
        $task = $gz.CopyToAsync($stream)
        while (-not $task.IsCompleted) {
            $layer.Substring(0,12) + ': Decompressing ' + (GetProgress -Current $fs.Position -Total $fs.Length) | WriteConsole
            Start-Sleep -Milliseconds 125
        }
    } finally {
        $gz.Dispose()
        $fs.Dispose()
        $stream.Dispose()
    }
    return $tar
}

function ExtractTar {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$Digest
    )
    try {
        $tar = $Path | Split-Path -Leaf
        $layer = $tar.Replace('.tar', '')
        $root = "$(GetPwrContentPath)\$Digest"
        MakeDirIfNotExist -Path $root | Out-Null
        $stream = [IO.File]::Open($Path, [IO.FileMode]::OpenOrCreate)
        $stream.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
        $buffer = New-Object byte[] 512
        while ($stream.Position -lt $stream.Length) {
            $stream.Read($buffer, 0, 512) | Out-Null
            $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 | Out-Null
            }
            if ($hdr.Type -in [char]103, [char]120) {
                $xhdr = ParsePaxHeader -Source $stream -Header $hdr
            } elseif ($hdr.Type -in [char]0, [char]48, [char]55 -and $filename.StartsWith('Files')) {
                CopyToFile -Source $stream -FilePath "$root\$file" -Size $size -Digest $layer
                $xhdr = $null
            } else {
                $stream.Seek($size, [IO.SeekOrigin]::Current) | Out-Null
                $xhdr = $null
            }
            $leftover = $size % 512
            if ($leftover -gt 0) {
                $stream.Seek(512 - ($size % 512), [IO.SeekOrigin]::Current) | Out-Null
            }
        }
    } finally {
        $stream.Dispose()
    }
}


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 = $true)]
        [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-"
    }
    return HttpRequest @params | HttpSend
}

function GetDigestForRef {
    param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true)]
        [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 = $true,
            ValueFromPipeline = $true)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $resp.Headers.GetValues('docker-content-digest')
}

function GetSize {
    param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true)]
        [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"
    MakeDirIfNotExist (GetPwrTempPath) | Out-Null
    try {
        $fs = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate)
        $fs.Seek(0, [IO.SeekOrigin]::End) | Out-Null
        $resp = GetBlob -Ref $Digest -StartByte $fs.Length
        $size = $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
        }
    } finally {
        $fs.Close()
    }
    return $path
}
function WriteHost {
    param (
        [string]$Line
    )
    Write-Information $Line -InformationAction Continue
}

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

function AsTag {
    param (
        [Parameter(
            ValueFromPipeline = $true)]
        [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 SortTags {
    param (
        [object[]]$Tags
    )
    return $Tags | Sort-Object -Property {$_.Major}, {$_.Minor}, {$_.Patch}, {$_.Build} -Descending
}

function AsTagString {
    param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true)]
        [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 = $true,
            ValueFromPipeline = $true)]
        [string]$Pkg
    )
    if ($Pkg -match '^([^:]+)(?::([^:]+))?(?:::?([^:]+))?$') {
        return @{
            Package = $Matches[1]
            Tag = $Matches[2] | AsTag
            Config = if ($Matches[3]) { $Matches[3] } else { 'default' }
        }
    }
    throw "failed to parse package: $Pkg"
}

function ResolveRemoteRef {
    param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true)]
        [object]$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 {
    $db = GetPwrDB
    $pkgs = @()
    foreach ($pkg in $db.pkgdb.keys) {
        foreach ($tag in $db.pkgdb.$pkg.keys) {
            $t = [Tag]::new($tag)
            $digest = if ($t.None) { $tag } else { $db.pkgdb.$pkg.$tag }
            $pkgs += [PSCustomObject]@{
                Package = $pkg
                Tag = $t
                Digest = $digest | AsDigest
                Size = $db.metadatadb.$digest.size | AsSize
                # Signers
            }
        }
    }
    if (-not $pkgs) {
        $pkgs = ,[PSCustomObject]@{
            Package = $null
            Tag = $null
            Digest = $null
            Size = $null
        }
    }
    return $pkgs
}

function ResolvePackageDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    return $db.pkgdb.$($Pkg.Package).$($Pkg.Tag | AsTagString)
}

function InstallPackage { # $db, $status
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    $digest = $Pkg.Digest
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    if ($null -ne $db.pkgdb.$name.$tag -and $digest -ne $db.pkgdb.$name.$tag) {
        $status = 'newer'
        $old = $db.pkgdb.$name.$tag
        $db.pkgdb.$name.Remove($tag)
        $db.pkgdb.$name.$old = $null
        if ($db.metadatadb.$old.refcount -gt 0) {
            $db.metadatadb.$old.refcount -= 1
        }
    }
    if ($null -eq $db.pkgdb.$name.$tag) {
        if ($db.metadatadb.$digest) {
            if ($db.pkgdb.$name.ContainsKey($digest)) {
                $db.pkgdb.$name.Remove($digest)
            }
            $status = 'tag'
            $db.metadatadb.$digest.refcount += 1
        } else {
            $status = 'new'
            $db.metadatadb.$digest = @{
                RefCount = 1
                Size = $Pkg.Size
            }
        }
        $db.pkgdb.$name += @{
            "$tag" = $digest
        }
    } else {
        $status = 'uptodate'
    }
    return $db, $status
}

function PullPackage {
    param (
        [Parameter(Mandatory,ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $manifest = $Pkg | ResolveRemoteRef | GetManifest
    $Pkg.Digest = $manifest | GetDigest
    $Pkg.Size = $manifest | GetSize
    WriteHost "$($pkg.Tag | AsTagString): Pulling $($Pkg.Package)"
    WriteHost "Digest: $($Pkg.Digest)"
    $db, $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 -eq 'new') {
            $manifest | SavePackage
        }
        WriteHost "Status: Downloaded newer package for $ref"
        $db | OutPwrDB
    }
}

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

function UninstallPackage { # $db, $digest, $err
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    $name = $Pkg.Package
    $key = $Pkg.Tag | AsTagString
    $table = $db.pkgdb.$name
    if (-not $db.pkgdb.ContainsKey($name) -or -not $table.ContainsKey($key)) {
        return $null, $null, "package not installed: ${name}:$key"
    }
    $digest = $table.$key
    $table.Remove($key)
    if ($table.Count -eq 0) {
        $db.pkgdb.Remove($name)
    }
    if ($db.metadatadb.$digest.refcount -gt 0) {
        $db.metadatadb.$digest.refcount -= 1
    }
    if (0 -eq $db.metadatadb.$digest.refcount) {
        $db.metadatadb.Remove($digest)
    } else {
        $digest = $null
    }
    return $db, $digest, $null
}

function RemovePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db, $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"
    }
    $db | OutPwrDB
}

function UninstallOrhpanedPackages {
    $db = GetPwrDB
    $rm = @()
    foreach ($digest in $db.metadatadb.keys) {
        $tbl = $db.metadatadb.$digest
        if ($tbl.refcount -eq 0) {
            $tbl.digest = $digest
            $rm += ,$tbl
        }
    }
    $empty = @()
    foreach ($i in $rm) {
        $db.metadatadb.Remove($i.digest)
        foreach ($pkg in $db.pkgdb.keys) {
            if ($db.pkgdb.$pkg.ContainsKey($i.digest)) {
                $db.pkgdb.$pkg.Remove($i.digest)
                if ($db.pkgdb.$pkg.Count -eq 0) {
                    $empty += $pkg

                }
            }
        }
    }
    foreach ($name in $empty) {
        $db.pkgdb.Remove($name)
    }
    return $db, $rm
}

function PrunePackages {
    $db, $pruned = UninstallOrhpanedPackages
    $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)", $true)
        }
    }
    WriteHost "Total reclaimed space: $($bytes | AsByteString)"
    $db | OutPwrDB
}

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

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 = $true,
            ValueFromPipeline = $true)]
        [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
    )
    $pkg = $Ref | AsPackage
    $digest = $pkg | ResolvePackageDigest
    switch (GetPwrPullPolicy) {
        '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 PwrPullPolicy '$(GetPwrPullPolicy)'"
        }
    }
    return $pkg
}

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

function SaveSessionState {
    Set-Variable -Name 'PwrSaveState' -Value (GetSessionState) -Scope Global
}

function ClearSessionState {
    $default = '__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', 'PwrSaveState', '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', 'pwrhome') {
            Remove-Item "env:$k" -Force -ErrorAction SilentlyContinue
        }
    }
    Remove-Item 'env:PwrLoadedPackages' -Force -ErrorAction SilentlyContinue
}

function RestoreSessionState {
    foreach ($v in $PwrSaveState.vars) {
        Set-Variable -Name $v.name -Value $v.value -Scope Global -Force -ErrorAction SilentlyContinue
    }
    foreach ($e in $PwrSaveState.env) {
        Set-Item -Path "env:$($e.name)" -Value $e.value -Force -ErrorAction SilentlyContinue
    }
    Remove-Variable 'PwrSaveState' -Force -Scope Global -ErrorAction SilentlyContinue
}

function GetPackageDefinition {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    if (-not $Digest) {
        return $null
    }
    $root = "$(GetPwrContentPath)\$($Digest.Substring('sha256:'.Length))"
    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) { ';' })"
            } else {
                $post = "$(if (-not $env:Path.StartsWith(';')) { ';' })$env:Path"
            }
        }
        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:PwrLoadedPackages -split ';')) {
        $Pkg | ConfigurePackage
        $env:PwrLoadedPackages += "$(if ($env:PwrLoadedPackages) { ';' })$digest"
        WriteHost "Status: Session configured for $ref"
    } else {
        WriteHost "Status: Session is up to date for $ref"
    }
}

function ExecuteScript {
    param (
        [Parameter(Mandatory)]
        [scriptblock]$Script,
        [Parameter(Mandatory)]
        [Collections.Hashtable[]]$Pkgs
    )
    try {
        SaveSessionState
        ClearSessionState
        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"
        & $Script
    } finally {
        RestoreSessionState
    }
}

$PwrHelp = @"
 
Usage: pwr 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
"@


# $PwrListHelp = @"

# Usage: pwr list [COMMAND]

# Lists packages

# Commands:
# remote, lists remote package
# "@

$PwrNoCommand = @"
 
To see avilable commands run
  pwr help
"@


$PwrRemoteNoCommand = @"
 
To see avilable commands run
  pwr remote help
"@


function Invoke-Airpower {
    param (
        [Parameter(ValueFromPipeline)]
        [object]$InputObject,
        [Parameter(ValueFromRemainingArguments, Position = 0)]
        [object[]]$Arguments
    )

    process {
        if ($InputObject) {
            $Arguments += $InputObject
        }
    }

    end {
        $ErrorActionPreference = 'Stop'
        if ($Arguments) {
            $first, $rest = $Arguments
            switch ($first) {
                {$_ -in 'v', 'version'} {
                    (Get-Module -Name Airpower).Version
                    return
                }
                'remote' {
                    if ($Arguments.Count -eq 2) {
                        switch ($Arguments[1]) {
                            'list' {
                                GetRemoteTags
                                return
                            }
                            'help' {
                                'todo'
                                return
                            }
                            default {
                                $PwrRemoteNoCommand
                                return
                            }
                        }
                    }
                }
                'list' {
                    GetLocalPackages
                    return
                }
                'load' {
                    if (-not $rest) {
                        $cfg = FindConfig
                        . $cfg
                        $rest = $PwrPackages
                        if (-not $rest) {
                            throw "no packages provided"
                        }
                    }
                    foreach ($p in $rest) {
                        $p | ResolvePackage | LoadPackage
                    }
                    return
                }
                'pull' {
                    if (-not $rest) {
                        $cfg = FindConfig
                        . $cfg
                        $rest = $PwrPackages
                        if (-not $rest) {
                            throw "no packages provided"
                        }
                    }
                    foreach ($p in $rest) {
                        $p | AsPackage | PullPackage
                    }
                    return
                }
                'prune' {
                    PrunePackages
                    return
                }
                {$_ -in 'remove', 'rm'} {
                    foreach ($p in $rest) {
                        $p | AsPackage | RemovePackage
                    }
                    return
                }
                'exec' {
                    if ($rest.Count -eq 0) {
                        throw "no scriptblock provided"
                    } elseif ($rest.Count -eq 1) {
                        $script = $rest
                        $cfg = FindConfig
                        . $cfg
                        $pkgs = $PwrPackages
                        if (-not $pkgs) {
                            throw "no packages provided"
                        }
                    } else {
                        $pkgs, $script = $rest[0..$($rest.Count - 2)], $rest[$($rest.Count - 1)]
                    }
                    if ($script -isnot [scriptblock]) {
                        throw "'$script' is not a script"
                    }
                    $resolved = @()
                    foreach ($p in $pkgs) {
                        $resolved += $p | ResolvePackage
                    }
                    ExecuteScript -Script $script -Pkgs $resolved
                    return
                }
                'run' {
                    $cfg = FindConfig
                    if (-not $cfg) {
                        throw "no config file found"
                    }
                    $first, $rest = $rest
                    if (-not $first) {
                        throw "no script provided"
                    }
                    . $cfg
                    $fn = Get-Item "function:Pwr$first"
                    if ($rest) {
                        & $fn @rest
                    } else {
                        & $fn
                    }
                    return
                }
                {$_ -in 'help', 'h'} {
                    $PwrHelp
                    return
                }
            }
        }
        throw $PwrNoCommand
    }
}

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