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) try { return $cli.SendAsync($Req, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() } catch { throw "An error occured while initiating an HTTP request; check your network connection and try again.`n+ Caused by: $_" } } 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 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 WriteConsole $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 ) if ($ProgressPreference -eq 'Continue') { [Console]::Write("`r$Line") } elseif ($ProgressPreference -ne 'SilentlyContinue') { throw "cannot write progress for ProgressPreference=$ProgressPreference" } } 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 GetPackageLayers { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) $layers = ($Resp | GetJsonResponse).layers $packageLayers = [System.Collections.Generic.List[PSObject]]::new() for ($i = 0; $i -lt $layers.Length; $i++) { if ($layers[$i].mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip' -and ($i -gt 0 -or $layer.length -eq 1)) { $packageLayers.Add($layers[$i]) } } return $packageLayers } function GetSize { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) $layers = $Resp | GetPackageLayers $size = 0 foreach ($layer in $layers) { $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 } try { foreach ($lock in $locks) { $tag = $lock.Key[2] $t = [Tag]::new($tag) $digest = if ($t.None) { $tag } else { $lock.Get() } $m = [Db]::Get(('metadatadb', $digest)) $pkgs += [LocalPackage]@{ Package = $lock.Key[1] Tag = $t Digest = $digest | AsDigest Size = $m.size | AsSize Orphaned = if ($m.orphaned) { [datetime]::Parse($m.orphaned) } } $lock.Unlock() } } finally { if ($locks) { $locks.Revert() } } 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) { if ($null -eq $m) { 'newer' } else { 'ref' } } else { 'uptodate' } $pLock.Put($digest) switch ($status) { {$_ -in 'new', 'newer'} { $mLock.Put(@{ RefCount = 1 Size = $Pkg.Size }) } {$_ -in 'newer', 'ref'} { $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) } {$_ -in 'tag', 'ref'} { 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) -and ($m = [Db]::Get($k)) -and $m.Size) { $size = $m.Size } else { $manifest = $ref | GetManifest $manifest | DebugRateLimit $size = $manifest | GetSize } $Pkg.Digest = $digest $Pkg.Size = $size $locks, $status = $Pkg | InstallPackage try { $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() } finally { if ($locks) { $locks.Revert() } } } function SavePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 SetCursorVisible $false try { $layers = $Resp | GetPackageLayers $digest = $Resp | GetDigest $temp = @() foreach ($layer in $layers) { try { $temp += $layer.Digest | SaveBlob | ExtractTarGz -Digest $digest "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole } finally { WriteConsole "`n" } } 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 DeleteDirectory { param ( [string]$Dir ) $name = [IO.Path]::GetRandomFileName() $tempDir = "$(GetPwrTempPath)\$name" [IO.Directory]::CreateDirectory($tempDir) | Out-Null try { Robocopy.exe $tempDir $Dir /MIR /PURGE | Out-Null [IO.Directory]::Delete($Dir) } finally { [IO.Directory]::Delete($tempDir) } } function RemovePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $locks, $digest, $err = $Pkg | UninstallPackage if ($null -ne $err) { throw $err } try { WriteHost "Untagged: $($Pkg.Package):$($pkg.Tag | AsTagString)" if ($null -ne $digest) { $content = $digest | ResolvePackagePath if (Test-Path $content -PathType Container) { DeleteDirectory $content } WriteHost "Deleted: $digest" } $refpath = $Pkg | ResolvePackageRefPath if (Test-Path -Path $refpath -PathType Container) { [IO.Directory]::Delete($refpath) } $locks.Unlock() } finally { if ($locks) { $locks.Revert() } } } 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 try { $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) { DeleteDirectory $content } } if ($pruned) { WriteHost "Total reclaimed space: $($bytes | AsByteString)" $locks.Unlock() } } finally { if ($locks) { $locks.Revert() } } } 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:///')) { $i = $ref.IndexOf('<') $cfg = if ($i -eq -1 -and $ref.Length -gt $i + 1) { 'default' } else { $ref.Substring($i+1).Trim() } return @{ Digest = $Ref Tag = @{} Config = $cfg } } $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 { [psvariable]::new($_.Name, $_.Value) }) 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) $i = $root.IndexOf('<') if ($i -ne -1) { $root = $root.Substring(0, $i).Trim() } } 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 } } <# .SYNOPSIS A package manager and environment to provide consistent tooling for software teams. .DESCRIPTION Airpower manages software packages using container technology and allows users to configure local PowerShell sessions to their need. Airpower seamlessly integrates common packages with a standardized project script to enable common build commands kept in source control for consistency. .LINK For detailed documentation and examples, visit https://github.com/airpwr/airpwr. #> 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 Commands: version Outputs the version of the module list Outputs a list of installed packages remote list Outputs an object of remote packages and versions pull Downloads packages load Loads packages into the PowerShell session exec Runs a user-defined scriptblock in a managed PowerShell session run Runs a user-defined scriptblock provided in a project file prune Deletes unreferenced packages remove Untags and deletes packages help Outputs usage for this command For detailed documentation and examples, visit https://github.com/airpwr/airpwr. "@ } function CheckForUpdates { try { $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" } } } catch { Write-Debug "failed to check for updates: $_" } } 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 CheckForUpdates PrunePackages -Auto } } |