App-Ariza.git | t/golden/ | installer-install.ps1
<#
.SYNOPSIS
Example App installer (Windows).
.DESCRIPTION
Generated by ariza from App::ExampleApp's ariza.toml. Do not edit this
file: edit ariza's resources/templates/install-windows.ps1.j2 and
re-run `ariza installers --app=<this repository>`.
Downloads the prebuilt Example App bundle for this machine from the
example-org/App-ExampleApp releases, verifies its sha256, and unpacks it into
%LOCALAPPDATA%\Example App\versions\<version>\. A junction named
`current` points at the version in use, and
%LOCALAPPDATA%\Example App\current\bin goes on your user PATH once.
Nothing is compiled, nothing needs administrator rights, and nothing
outside %LOCALAPPDATA%\Example App and your user PATH is written.
The installed application is then run once, so that whatever a first
launch has to do is done here rather than the first time you want the
program.
.PARAMETER Version
Install a specific release tag (e.g. -Version v1.2.3) rather than the
latest one.
.PARAMETER Url
Install this archive instead, bypassing GitHub entirely. A local file
path works as well as a URL. Also settable as $env:EXAMPLEAPP_BUNDLE_URL.
.PARAMETER InsecureNoVerify
With -Url only: proceed even when the source has no sibling .sha256.
.EXAMPLE
irm https://raw.githubusercontent.com/example-org/App-ExampleApp/HEAD/install.ps1 | iex
.EXAMPLE
# With arguments, a piped script needs to become a script block first:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/example-org/App-ExampleApp/HEAD/install.ps1))) -Version v1.2.3
#>
[CmdletBinding()]
param(
[string] $Version,
[string] $Url,
[switch] $InsecureNoVerify,
[string] $ArizaUpdateCandidate,
[string] $ArizaHandoff,
[string] $ArizaNonce,
[string] $ArizaExpectedCurrent
)
$AppDisplay = 'Example App'
$AppExec = 'exampleapp'
$AppRepo = 'example-org/App-ExampleApp'
# The platforms App::ExampleApp publishes bundles for. Detection produces one
# of these strings or nothing at all; there is deliberately no "closest
# match", because a bundle for the wrong architecture does not run.
$ArizaSlugs = @('windows-x86_64')
$ArizaRoot = Join-Path $env:LOCALAPPDATA $AppDisplay
$ArizaVersions = Join-Path $ArizaRoot 'versions'
$ArizaBinDir = Join-Path (Join-Path $ArizaRoot 'current') 'bin'
$ArizaState = Join-Path $ArizaRoot '.ariza\update-v1'
$ArizaPrivate = [bool]($ArizaUpdateCandidate -or $ArizaHandoff -or $ArizaNonce -or $ArizaExpectedCurrent)
############################# common runtime ##################################
###############################################################################
# ariza installer runtime (Windows / PowerShell)
#
# Inlined verbatim into install.ps1 and uninstall.ps1 at render time, so a
# generated installer is one self-contained file.
#
# Adapted from the pre-bundle installers' template/lib/common-windows.ps1:
# the registry, the package managers and the shortcut helpers are gone (a
# bundle installs none of that), the user-PATH machinery is kept, because
# an unguarded PATH write is how a user ends up with the same directory in
# it eleven times.
#
# Callers must have set: $AppDisplay, $AppExec, $ArizaRoot.
###############################################################################
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Ariza-Log { param([string]$Message) Write-Host "==> $Message" -ForegroundColor Blue }
function Ariza-Ok { param([string]$Message) Write-Host "ok $Message" -ForegroundColor Green }
function Ariza-Warn { param([string]$Message) Write-Host "!! $Message" -ForegroundColor Yellow }
function Ariza-Err {
param([string]$Message)
Write-Host "error $Message" -ForegroundColor Red
exit 1
}
# ---- downloading -------------------------------------------------------------
function Ariza-Download {
param([string]$Url, [string]$Dest)
# -UseBasicParsing keeps this working on a Server Core box with no
# Internet Explorer engine, which is where Invoke-WebRequest's default
# parser falls over.
Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing
}
# ---- checksums ---------------------------------------------------------------
function Ariza-Sha256 {
param([string]$Path)
# Use the .NET implementation directly. Get-FileHash normally ships in
# Microsoft.PowerShell.Utility, but minimal/non-standard PowerShell 5.1
# environments can lack that cmdlet while still providing the runtime
# APIs the installer needs.
$stream = [IO.File]::OpenRead($Path)
try {
$sha = [Security.Cryptography.SHA256]::Create()
try {
$hash = $sha.ComputeHash($stream)
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
}
finally {
$sha.Dispose()
}
}
finally {
$stream.Dispose()
}
}
function Ariza-FirstWord {
param([string]$Path)
# A `<hex> <filename>` checksum file, as shasum -c reads.
$line = (Get-Content -LiteralPath $Path -TotalCount 1)
if (-not $line) { return '' }
return ($line -split '\s+')[0].ToLower()
}
function Ariza-VerifySha256 {
param([string]$Path, [string]$Expected)
$actual = Ariza-Sha256 $Path
if ($actual -ne $Expected.ToLower()) {
Ariza-Err "checksum mismatch for $(Split-Path -Leaf $Path): expected $Expected, got $actual -- not installing"
}
}
# ---- unpacking ---------------------------------------------------------------
function Ariza-Extract {
param([string]$Archive, [string]$Into)
New-Item -ItemType Directory -Path $Into -Force | Out-Null
if ($Archive.ToLower().EndsWith('.zip')) {
Expand-Archive -LiteralPath $Archive -DestinationPath $Into -Force
}
else {
# bsdtar has shipped in Windows since 10 1803 and reads .tar.gz,
# which is what ariza packages every platform's bundle as.
$tar = Get-Command tar.exe -ErrorAction SilentlyContinue
if (-not $tar) {
Ariza-Err "need tar.exe (Windows 10 1803 or later) to unpack $(Split-Path -Leaf $Archive)"
}
& $tar.Path -x -z -f $Archive -C $Into
if ($LASTEXITCODE -ne 0) {
Ariza-Err "tar could not unpack $(Split-Path -Leaf $Archive) (exit $LASTEXITCODE)"
}
}
}
# ---- user PATH ---------------------------------------------------------------
#
# The user environment in the registry (HKCU\Environment), never the
# machine one: nothing here needs administrator rights, and nothing here
# should be visible to other accounts.
function Ariza-PersistPath {
param([string]$Dir)
$current = [Environment]::GetEnvironmentVariable('PATH', 'User')
if (-not $current) { $current = '' }
$parts = $current -split ';' | Where-Object { $_ -ne '' }
if ($parts -notcontains $Dir) {
[Environment]::SetEnvironmentVariable('PATH', ((@($Dir) + $parts) -join ';'), 'User')
Ariza-Ok "added $Dir to your PATH"
}
if (($env:PATH -split ';') -notcontains $Dir) {
$env:PATH = "$Dir;$env:PATH"
}
}
function Ariza-UnpersistPath {
param([string]$Dir)
$current = [Environment]::GetEnvironmentVariable('PATH', 'User')
if (-not $current) { return }
$parts = $current -split ';' | Where-Object { $_ -ne '' }
if ($parts -notcontains $Dir) { return }
$kept = $parts | Where-Object { $_ -ne $Dir }
[Environment]::SetEnvironmentVariable('PATH', ($kept -join ';'), 'User')
Ariza-Ok "removed $Dir from your PATH"
}
# ---- the `current` junction --------------------------------------------------
#
# A junction rather than a symbolic link: creating a symlink on Windows
# needs either administrator rights or Developer Mode, and an installer
# that demands either for a per-user install is one nobody runs.
function Ariza-RemoveLink {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
$item = Get-Item -LiteralPath $Path -Force
if (-not $item.LinkType) {
Ariza-Err "$Path exists and is not a junction -- move it aside and re-run"
}
# .Delete() removes the junction itself; Remove-Item -Recurse would
# walk through it and delete the target's contents.
$item.Delete()
}
function Ariza-PointCurrent {
param([string]$Target)
$link = Join-Path $ArizaRoot 'current'
Ariza-RemoveLink $link
New-Item -ItemType Junction -Path $link -Target $Target | Out-Null
}
###############################################################################
function Ariza-DetectSlug {
$arch = $env:PROCESSOR_ARCHITECTURE
if (-not $arch) { $arch = '' }
switch ($arch.ToUpper()) {
'AMD64' { return 'windows-x86_64' }
'ARM64' { return 'windows-arm64' }
'X86' {
# A 32-bit PowerShell on a 64-bit machine reports x86; ask
# what the hardware actually is before giving up on it.
if ($env:PROCESSOR_ARCHITEW6432 -eq 'AMD64') { return 'windows-x86_64' }
if ($env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { return 'windows-arm64' }
return ''
}
}
return ''
}
function Ariza-VersionFromName {
param([string]$Name)
# ariza names a bundle <exec>-<version>-<slug>. Stripping a known slug
# off the end rather than splitting on dashes is what makes a version
# containing one (1.0-rc1) parse correctly.
$n = $Name
foreach ($ext in @('.tar.gz', '.tgz', '.zip')) {
if ($n.ToLower().EndsWith($ext)) { $n = $n.Substring(0, $n.Length - $ext.Length) }
}
if (-not $n.StartsWith("$AppExec-")) { return '' }
$n = $n.Substring($AppExec.Length + 1)
foreach ($slug in $ArizaSlugs) {
if ($n.EndsWith("-$slug")) { return $n.Substring(0, $n.Length - $slug.Length - 1) }
}
return ''
}
function Ariza-LatestTag {
# The releases/latest redirect names the tag, so this needs no JSON
# parsing and no API token.
$url = "https://github.com/$AppRepo/releases/latest"
try {
$resp = Invoke-WebRequest -Uri $url -UseBasicParsing -MaximumRedirection 5
}
catch {
return ''
}
$final = ''
if ($resp.BaseResponse.PSObject.Properties.Name -contains 'ResponseUri') {
$final = $resp.BaseResponse.ResponseUri.AbsoluteUri # PowerShell 5.1
}
elseif ($resp.BaseResponse.PSObject.Properties.Name -contains 'RequestMessage') {
$final = $resp.BaseResponse.RequestMessage.RequestUri.AbsoluteUri # PowerShell 7
}
if (-not $final) { return '' }
return ($final -split '/')[-1]
}
function Ariza-LinkBin {
Ariza-PersistPath $ArizaBinDir
}
function Ariza-HasEntryPoint {
param([string]$Dir)
# A bundle launches from bin\<exec>.exe where one was staged, and
# from bin\<exec>.cmd otherwise. Either is a complete install: the
# directory goes on PATH, and PATHEXT already prefers the executable
# over the batch file where both are there.
foreach ($leaf in @("bin\$AppExec.exe", "bin\$AppExec.cmd")) {
if (Test-Path -LiteralPath (Join-Path $Dir $leaf)) { return $true }
}
return $false
}
function Ariza-FullPath {
param([string]$Path)
return [IO.Path]::GetFullPath($Path).TrimEnd('\')
}
function Ariza-ValidatePrivate {
if (-not $ArizaPrivate) { return }
if (-not $ArizaUpdateCandidate -or -not $ArizaHandoff -or
-not $ArizaNonce -or -not $ArizaExpectedCurrent) {
Ariza-Err 'private update mode requires candidate, handoff, nonce, and expected-current'
}
if ($ArizaUpdateCandidate -cnotmatch '^[0-9]+\.[0-9]+\.[0-9]+$') {
Ariza-Err 'private update candidate must be a bare ASCII x.y.z version'
}
if ($ArizaNonce -cnotmatch '^[0-9a-f]{64}$') {
Ariza-Err 'private update nonce must be 64 lowercase hexadecimal characters'
}
if ($Version -or $Url -or $InsecureNoVerify -or $env:EXAMPLEAPP_BUNDLE_URL) {
Ariza-Err 'public version/url/insecure options are forbidden in private update mode'
}
if (-not (Test-Path -LiteralPath $ArizaExpectedCurrent -PathType Container)) {
Ariza-Err 'expected current bundle does not exist'
}
foreach ($statePath in @((Join-Path $ArizaRoot '.ariza'), $ArizaState)) {
if (-not (Test-Path -LiteralPath $statePath)) { continue }
$stateItem = Get-Item -LiteralPath $statePath -Force
if (($stateItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
Ariza-Err 'private update state must not be a reparse point'
}
}
New-Item -ItemType Directory -Path $ArizaState -Force | Out-Null
$handoffParent = Ariza-FullPath (Split-Path -Parent $ArizaHandoff)
$handoffName = Split-Path -Leaf $ArizaHandoff
if ($handoffParent -ceq (Ariza-FullPath $ArizaState)) {
if ($handoffName -cne 'handoff' -and -not $handoffName.StartsWith('handoff.')) {
Ariza-Err 'private handoff has an invalid name'
}
}
else {
$handoffDir = Get-Item -LiteralPath $handoffParent -Force -ErrorAction SilentlyContinue
if (-not $handoffDir -or
($handoffDir.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
(Ariza-FullPath (Split-Path -Parent $handoffParent)) -cne (Ariza-FullPath $ArizaState) -or
-not $handoffDir.Name.StartsWith('handoff-') -or $handoffName -cne 'result') {
Ariza-Err 'private handoff must be inside update-v1 state'
}
}
if (Test-Path -LiteralPath $ArizaHandoff) {
Ariza-Err 'private handoff path already exists'
}
}
function Ariza-PhysicalLinkTarget {
param([string]$Link)
if (-not (Test-Path -LiteralPath $Link)) { return '' }
$item = Get-Item -LiteralPath $Link -Force
if (-not $item.LinkType) { Ariza-Err "$Link exists and is not a junction" }
return Ariza-FullPath $item.Target
}
function Ariza-VersionForPhysical {
param([string]$Physical)
if (-not $Physical) { return '' }
$parent = Ariza-FullPath (Split-Path -Parent $Physical)
if ($parent -cne (Ariza-FullPath $ArizaVersions)) {
Ariza-Err 'managed pointer target is outside the versions directory'
}
return Split-Path -Leaf $Physical
}
function Ariza-SetJunctionAtomic {
param([string]$Name, [string]$Target)
$link = Join-Path $ArizaRoot $Name
$new = "$link.new-$PID"
if (Test-Path -LiteralPath $new) { (Get-Item -LiteralPath $new -Force).Delete() }
New-Item -ItemType Junction -Path $new -Target $Target | Out-Null
Ariza-RemoveLink $link
Move-Item -LiteralPath $new -Destination $link
}
function Ariza-WriteAtomicText {
param([string]$Path, [string]$Text)
$tmp = "$Path.tmp-$PID"
[IO.File]::WriteAllText($tmp, $Text, (New-Object Text.UTF8Encoding($false)))
Move-Item -LiteralPath $tmp -Destination $Path -Force
}
function Ariza-ValidatePrivateManifest {
param([string]$Top)
if (-not $ArizaPrivate) { return }
$path = Join-Path $Top 'ariza-manifest.json'
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
Ariza-Err 'private update bundle has no ariza-manifest.json'
}
try { $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json }
catch { Ariza-Err 'private update bundle has malformed manifest JSON' }
if ($manifest.'ariza-manifest' -ne 1 -or
$manifest.app.name -cne 'App::ExampleApp' -or
$manifest.app.exec -cne 'exampleapp' -or
$manifest.app.version -cne $ArizaUpdateCandidate) {
Ariza-Err 'private update bundle identity does not match the candidate'
}
$updates = $manifest.updates
if (-not $updates -or $updates.protocol -ne 1 -or -not $updates.enabled -or
$updates.repository -cne 'example-org/App-ExampleApp' -or
$updates.coordinator -cne 'libexec/ariza/update.raku' -or
$updates.installer -cne 'libexec/ariza/install.ps1') {
Ariza-Err 'private update bundle has invalid update protocol metadata'
}
foreach ($relative in @($updates.coordinator, $updates.installer, $updates.'application-target')) {
if (-not $relative -or [IO.Path]::IsPathRooted($relative) -or $relative -match '(^|[\\/])\.\.([\\/]|$)') {
Ariza-Err 'private update bundle names an unsafe updater path'
}
if (-not (Test-Path -LiteralPath (Join-Path $Top $relative) -PathType Leaf)) {
Ariza-Err "private update bundle is missing $relative"
}
}
}
function Ariza-RecordDeferredCleanup {
param([string]$Dir)
$cleanup = Join-Path $ArizaState 'cleanup'
New-Item -ItemType Directory -Path $cleanup -Force | Out-Null
$name = Split-Path -Leaf $Dir
Ariza-WriteAtomicText (Join-Path $cleanup $name) ((Ariza-FullPath $Dir) + "`n")
}
function Ariza-RunDeferredCleanup {
$cleanup = Join-Path $ArizaState 'cleanup'
if (-not (Test-Path -LiteralPath $cleanup -PathType Container)) { return }
$current = Ariza-PhysicalLinkTarget (Join-Path $ArizaRoot 'current')
$previous = Ariza-PhysicalLinkTarget (Join-Path $ArizaRoot 'previous')
foreach ($record in @(Get-ChildItem -LiteralPath $cleanup -File -ErrorAction SilentlyContinue)) {
if ($record.Length -gt 4096) { continue }
$path = (Get-Content -LiteralPath $record.FullName -TotalCount 1).Trim()
if (-not $path -or (Ariza-FullPath (Split-Path -Parent $path)) -cne (Ariza-FullPath $ArizaVersions)) {
Remove-Item -LiteralPath $record.FullName -Force; continue
}
if ($path -ceq $current -or $path -ceq $previous -or
($ArizaPrivate -and $path -ceq (Ariza-FullPath $ArizaExpectedCurrent))) { continue }
if (-not (Test-Path -LiteralPath $path)) {
Remove-Item -LiteralPath $record.FullName -Force; continue
}
$item = Get-Item -LiteralPath $path -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
# A directory replaced by a reparse point after recording is not
# the object the installer decided was safe to delete.
continue
}
try {
Remove-Item -LiteralPath $path -Recurse -Force
Remove-Item -LiteralPath $record.FullName -Force
Ariza-Log "removed deferred superseded version $($item.Name)"
}
catch { }
}
}
function Ariza-Prune {
param([string]$Keep, [string]$Previous)
$dirs = Get-ChildItem -LiteralPath $ArizaVersions -Directory -ErrorAction SilentlyContinue
foreach ($dir in $dirs) {
if ($dir.Name -ceq $Keep -or $dir.Name -ceq $Previous) { continue }
if (($dir.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
try {
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
Ariza-Log "removed superseded version $($dir.Name)"
}
catch {
try { Ariza-RecordDeferredCleanup $dir.FullName } catch { }
Ariza-Warn "could not remove superseded version $($dir.Name); cleanup was deferred"
}
}
}
function Ariza-Commit {
param([string]$NewVersion)
$currentLink = Join-Path $ArizaRoot 'current'
$previousLink = Join-Path $ArizaRoot 'previous'
$before = Ariza-PhysicalLinkTarget $currentLink
if ($ArizaPrivate) {
if (-not $before -or (Ariza-FullPath $before) -cne (Ariza-FullPath $ArizaExpectedCurrent)) {
Ariza-Err 'managed current changed while the update was being prepared'
}
}
$oldPrevious = Ariza-PhysicalLinkTarget $previousLink
$previousVersion = Ariza-VersionForPhysical $before
$newTarget = Join-Path $ArizaVersions $NewVersion
$journal = Join-Path $ArizaState 'transaction'
New-Item -ItemType Directory -Path $ArizaState -Force | Out-Null
Ariza-WriteAtomicText $journal ("protocol=1`nold=$before`nprevious=$oldPrevious`nnew=$newTarget`n")
try {
Ariza-SetJunctionAtomic 'current' $newTarget
if ($before) { Ariza-SetJunctionAtomic 'previous' $before }
elseif (Test-Path -LiteralPath $previousLink) { Ariza-RemoveLink $previousLink }
# Clear the recovery journal before emitting success. The handoff is
# then the final fallible commit step; no later error can turn a
# natural application exit 75 into a false authenticated relaunch.
Remove-Item -LiteralPath $journal -Force
if ($ArizaPrivate) {
$record = "protocol=1`nnonce=$ArizaNonce`ncandidate=$ArizaUpdateCandidate`n"
Ariza-WriteAtomicText $ArizaHandoff $record
}
}
catch {
# A handoff-less switch is not authenticatable. Restore both pointers
# while every target is still retained.
if ($before) { Ariza-SetJunctionAtomic 'current' $before }
elseif (Test-Path -LiteralPath $currentLink) { Ariza-RemoveLink $currentLink }
if ($oldPrevious) { Ariza-SetJunctionAtomic 'previous' $oldPrevious }
elseif (Test-Path -LiteralPath $previousLink) { Ariza-RemoveLink $previousLink }
Remove-Item -LiteralPath $journal -Force -ErrorAction SilentlyContinue
throw
}
# Retention failures are caught and deferred, so nothing fallible follows
# a private handoff record.
Ariza-Prune $NewVersion $previousVersion
}
function Ariza-RecoverTransaction {
$journal = Join-Path $ArizaState 'transaction'
if (-not (Test-Path -LiteralPath $journal -PathType Leaf)) { return }
$journalItem = Get-Item -LiteralPath $journal -Force
if ($journalItem.Length -gt 4096 -or
($journalItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
Ariza-Err 'installer transaction journal is unsafe; refusing to change managed pointers'
}
$lines = @(Get-Content -LiteralPath $journal)
if (@($lines | Where-Object { $_ -ceq 'protocol=1' }).Count -ne 1 -or
@($lines | Where-Object { $_.StartsWith('old=') }).Count -ne 1 -or
@($lines | Where-Object { $_.StartsWith('previous=') }).Count -ne 1 -or
@($lines | Where-Object { $_.StartsWith('new=') }).Count -ne 1) {
Ariza-Err 'installer transaction journal is corrupt; refusing to change managed pointers'
}
$oldLine = $lines | Where-Object { $_.StartsWith('old=') } | Select-Object -First 1
$previousLine = $lines | Where-Object { $_.StartsWith('previous=') } | Select-Object -First 1
if (-not $oldLine -or -not $previousLine) {
Ariza-Err 'installer transaction journal is corrupt; refusing to change managed pointers'
}
$old = $oldLine.Substring(4)
$oldPrevious = $previousLine.Substring(9)
if ($old -and (Test-Path -LiteralPath $old -PathType Container) -and
(Ariza-FullPath (Split-Path -Parent $old)) -ceq (Ariza-FullPath $ArizaVersions)) {
Ariza-SetJunctionAtomic 'current' $old
if ($oldPrevious -and (Test-Path -LiteralPath $oldPrevious -PathType Container) -and
(Ariza-FullPath (Split-Path -Parent $oldPrevious)) -ceq (Ariza-FullPath $ArizaVersions)) {
Ariza-SetJunctionAtomic 'previous' $oldPrevious
}
elseif (Test-Path -LiteralPath (Join-Path $ArizaRoot 'previous')) {
Ariza-RemoveLink (Join-Path $ArizaRoot 'previous')
}
Remove-Item -LiteralPath $journal -Force
Ariza-Warn 'recovered an interrupted installer transaction'
}
else { Ariza-Err 'installer transaction journal names an unsafe old target' }
}
function Ariza-CheckExisting {
param([string]$InstalledVersion)
# Returns $true when that version is already installed and usable, in
# which case the junction and PATH are repaired on the way past: a
# re-run is the obvious thing to try when something has gone missing,
# and it should fix it.
$dir = Join-Path $ArizaVersions $InstalledVersion
if (-not (Test-Path -LiteralPath $dir)) { return $false }
if (-not (Ariza-HasEntryPoint $dir)) {
Ariza-Warn "$dir is incomplete -- installing it again"
Remove-Item -LiteralPath $dir -Recurse -Force
return $false
}
$current = Ariza-PhysicalLinkTarget (Join-Path $ArizaRoot 'current')
if (-not $current -or (Ariza-FullPath $current) -cne (Ariza-FullPath $dir)) {
Ariza-Commit $InstalledVersion
}
Ariza-LinkBin
Ariza-Ok "$AppDisplay $InstalledVersion is already installed"
return $true
}
function Ariza-Warmup {
# Run the app once, now, through the same junction the user's shell
# will take. A first launch pages a few hundred megabytes off a cold
# disk and builds whatever per-user state the app keeps; doing it
# here means it happens while an installer is on screen saying so,
# instead of the first time somebody actually wants the program.
#
# Never fatal. The bundle is installed and its sha256 was checked
# before anything was moved into place, so a warm-up that fails on
# one machine is far more likely to be that machine -- no console, a
# policy, an over-eager scanner -- than a broken release. Refusing to
# finish the install over it would take a working program away from
# a user who has one.
$bin = ''
foreach ($leaf in @("$AppExec.exe", "$AppExec.cmd")) {
$candidate = Join-Path $ArizaBinDir $leaf
if (Test-Path -LiteralPath $candidate) { $bin = $candidate; break }
}
if (-not $bin) { return }
# The arguments come from the app's ariza.toml and are written out
# already quoted, once as the argument list and once as the single
# string the message names. `@warmArgs` is splatting -- an array
# written inline at the call site would be passed as one argument
# with spaces in it, which is a different command entirely.
$warmArgs = @('--version')
$what = '--version'
Ariza-Log 'warming up -- the first launch does the work the rest never repeat'
$failed = ''
try {
& $bin @warmArgs *> $null
# A native command's exit code is not an error to PowerShell, so
# it has to be looked at on purpose.
if ($LASTEXITCODE -ne 0) { $failed = "exit code $LASTEXITCODE" }
}
catch {
$failed = $_.Exception.Message
}
if ($failed) {
Ariza-Warn "warm-up failed: $AppExec $what did not complete ($failed)"
Ariza-Warn "$AppDisplay is installed and its download was verified -- try running it; the first launch may just take longer"
}
else {
Ariza-Ok 'ready'
}
}
function Ariza-Report {
param([string]$InstalledVersion)
Write-Host ''
Write-Host " run it: $AppExec"
Write-Host " installed in: $(Join-Path $ArizaVersions $InstalledVersion)"
Write-Host " uninstall: irm https://raw.githubusercontent.com/example-org/App-ExampleApp/HEAD/uninstall.ps1 | iex"
Write-Host ''
Write-Host " Open a new terminal to pick up the PATH change."
}
function Ariza-Main {
if ($ArizaPrivate) { Ariza-ValidatePrivate }
else { New-Item -ItemType Directory -Path $ArizaState -Force | Out-Null }
Ariza-RecoverTransaction
Ariza-RunDeferredCleanup
$src = if ($Url) { $Url } elseif ($env:EXAMPLEAPP_BUNDLE_URL) { $env:EXAMPLEAPP_BUNDLE_URL } else { '' }
$override = [bool]$src
$installVer = ''
if ($ArizaPrivate) {
$slug = Ariza-DetectSlug
if (-not $slug -or $ArizaSlugs -notcontains $slug) {
Ariza-Err 'no private update bundle for this Windows platform'
}
$installVer = $ArizaUpdateCandidate
$src = "https://github.com/$AppRepo/releases/download/$ArizaUpdateCandidate/$AppExec-$ArizaUpdateCandidate-$slug.tar.gz"
$override = $false
Ariza-Log "$AppDisplay $installVer update for $slug"
}
elseif ($override) {
# An explicit source bypasses GitHub completely. It is how a
# release candidate, an air-gapped copy or an offline test gets
# installed, so it accepts a plain file path as readily as a URL.
$installVer = Ariza-VersionFromName (Split-Path -Leaf $src)
Ariza-Log "$AppDisplay from $src"
}
else {
$slug = Ariza-DetectSlug
if (-not $slug -or $ArizaSlugs -notcontains $slug) {
Ariza-Err "no prebuilt $AppDisplay bundle for Windows $env:PROCESSOR_ARCHITECTURE yet -- see https://github.com/$AppRepo/releases for what is published"
}
$tag = if ($Version) { $Version } else { Ariza-LatestTag }
if (-not $tag) {
Ariza-Err "could not read the latest release tag from https://github.com/$AppRepo/releases/latest -- pass -Version to name one"
}
$installVer = $tag -replace '^v', ''
$src = "https://github.com/$AppRepo/releases/download/$tag/$AppExec-$installVer-$slug.tar.gz"
Ariza-Log "$AppDisplay $installVer for $slug"
}
# Every path that reaches the parting message warms up first,
# including the one where nothing was downloaded -- a re-run is what
# somebody tries when the last one did not take.
if (-not $ArizaPrivate -and $installVer -and (Ariza-CheckExisting $installVer)) {
Ariza-Warmup
Ariza-Report $installVer
return
}
$tmp = Join-Path ([IO.Path]::GetTempPath()) "$AppExec-install-$PID"
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
$staging = Join-Path $ArizaRoot ".staging-$PID"
try {
$archive = Join-Path $tmp (Split-Path -Leaf $src)
if (Test-Path -LiteralPath $src) {
Copy-Item -LiteralPath $src -Destination $archive -Force
}
else {
Ariza-Log "downloading $src"
Ariza-Download $src $archive
}
$expected = ''
$digestSrc = "$src.sha256"
if (Test-Path -LiteralPath $digestSrc) {
$expected = Ariza-FirstWord $digestSrc
}
elseif (-not (Test-Path -LiteralPath $src)) {
try {
Ariza-Download $digestSrc "$archive.sha256"
$expected = Ariza-FirstWord "$archive.sha256"
}
catch {
$expected = ''
}
}
if ($expected) {
Ariza-VerifySha256 $archive $expected
Ariza-Ok 'sha256 verified'
}
elseif ($override -and $InsecureNoVerify) {
# Only reachable for a source the user named themselves, and
# only when they asked for it in as many words. A published
# release always has a checksum beside it, so its absence
# there is a tampered or half-uploaded release and stays fatal.
Ariza-Warn "NOT VERIFIED: no $(Split-Path -Leaf $digestSrc) beside the source you gave, and -InsecureNoVerify was passed"
}
else {
Ariza-Err "no checksum published at $digestSrc -- refusing to install a bundle that cannot be verified"
}
# Unpacked inside the data directory rather than in TEMP, so the
# move into place is a rename on the same volume: the new version
# appears complete or not at all, and `current` is repointed only
# after it is there.
New-Item -ItemType Directory -Path $ArizaVersions -Force | Out-Null
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
Ariza-Extract $archive $staging
$tops = @(Get-ChildItem -LiteralPath $staging -Directory)
if ($tops.Count -ne 1) {
Ariza-Err "that archive does not look like a $AppDisplay bundle (expected one directory inside it)"
}
$top = $tops[0]
# What the bundle says it is beats what the archive was called: a
# versions\<x> directory that does not contain <x> would make
# every later "already installed" check a lie.
$actual = Ariza-VersionFromName $top.Name
if (-not $actual) {
if (-not $installVer) {
Ariza-Err "cannot tell which version $($top.Name) is -- expected $AppExec-<version>-<platform>"
}
}
else {
if ($installVer -and $actual -ne $installVer) {
if ($ArizaPrivate) {
Ariza-Err "private update archive contains $actual, expected $installVer"
}
Ariza-Warn "that archive is named $installVer but contains $actual -- installing it as $actual"
}
$installVer = $actual
}
Ariza-ValidatePrivateManifest $top.FullName
if (-not $ArizaPrivate -and (Ariza-CheckExisting $installVer)) {
Ariza-Warmup
Ariza-Report $installVer
return
}
$dest = Join-Path $ArizaVersions $installVer
if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force }
Move-Item -LiteralPath $top.FullName -Destination $dest
# Stamped with the install time, not the build time: the archive
# carries the directory's original timestamp, which is the same
# for every bundle cut from one source tree, and pruning by age
# has to mean "the one I installed before this one".
(Get-Item -LiteralPath $dest).LastWriteTime = Get-Date
if (-not (Ariza-HasEntryPoint $dest)) {
Ariza-Err "the unpacked bundle has neither bin\$AppExec.exe nor bin\$AppExec.cmd -- not touching your existing install"
}
Ariza-Commit $installVer
if ($ArizaPrivate) {
Ariza-Ok "$AppDisplay $installVer installed"
return
}
Ariza-LinkBin
Ariza-Ok "$AppDisplay $installVer installed"
Ariza-Warmup
Ariza-Report $installVer
}
finally {
if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Recurse -Force }
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
}
}
Ariza-Main