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