#requires -Version 5.1 [CmdletBinding()] param( [string]$Version = '0.24.0', [string]$InstallDir = $env:TERRA_INSTALL_DIR, [switch]$NoModifyPath, [switch]$Force ) # A child scope keeps irm | iex from changing the caller's preferences. & { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'This installer requires Windows PowerShell 5.1 or PowerShell 7 on Windows.' } # Process environment variables can report AMD64 under ARM64 emulation. $architecture = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE switch ($architecture) { 'AMD64' { $archiveArch = 'x86_64' } 'ARM64' { $archiveArch = 'arm64' } default { throw "Unsupported Windows architecture: $architecture. Terra supports x64 and ARM64 Windows." } } $Version = $Version -replace '^v', '' if ($Version -cnotmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$') { throw 'Version must be a release version such as 1.2.3.' } if (-not $InstallDir) { $InstallDir = Join-Path $env:LOCALAPPDATA 'Terra\bin' } $InstallDir = [IO.Path]::GetFullPath($InstallDir) if ($InstallDir.Contains(';')) { throw 'InstallDir must not contain a semicolon.' } $binary = Join-Path $InstallDir 'terra.exe' $receipt = Join-Path $InstallDir '.terra-install' $names = @('terra.exe', 'terra-LICENSE', '.terra-install') $lock = $null $stage = $null $keepStage = $false $originalProtocol = [Net.ServicePointManager]::SecurityProtocol try { [Net.ServicePointManager]::SecurityProtocol = $originalProtocol -bor [Net.SecurityProtocolType]::Tls12 [IO.Directory]::CreateDirectory($InstallDir) | Out-Null try { $lock = [IO.File]::Open((Join-Path $InstallDir '.terra-install.lock'), 'OpenOrCreate', 'ReadWrite', 'None') } catch { throw "Cannot lock $InstallDir. Another installer may be running: $($_.Exception.Message)" } foreach ($name in $names) { $path = Join-Path $InstallDir $name if (Test-Path -LiteralPath $path) { $item = Get-Item -LiteralPath $path -Force if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Refusing to replace directory or link: $path" } } } $owned = $false if ((Test-Path -LiteralPath $binary) -and (Test-Path -LiteralPath $receipt)) { $lines = @(Get-Content -LiteralPath $receipt) $owned = $lines.Count -eq 3 -and $lines[0] -ceq 'terra-standalone-v1' -and $lines[2] -eq (Get-FileHash -LiteralPath $binary -Algorithm SHA256).Hash } if (-not $Force -and -not $owned) { foreach ($name in $names) { if (Test-Path -LiteralPath (Join-Path $InstallDir $name)) { throw "Existing installation is not owned by Terra's installer. Use -Force to replace it." } } $existing = Get-Command terra -ErrorAction SilentlyContinue if ($existing) { throw 'Terra is already on PATH. Uninstall it or use -Force to install here.' } } $stage = Join-Path $InstallDir ('.terra-stage-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($stage) | Out-Null $archiveName = "terra_${Version}_windows_${archiveArch}.zip" $base = "https://github.com/tryterra/terra-cli/releases/download/v$Version" $archivePath = Join-Path $stage $archiveName $checksums = Join-Path $stage 'checksums.txt' Write-Host "Installing Terra $Version..." Invoke-WebRequest -UseBasicParsing -Uri "$base/terra-windows-checksums.txt" -OutFile $checksums -TimeoutSec 120 $pattern = '^([0-9a-fA-F]{64})\s+\*?' + [regex]::Escape($archiveName) + '$' $hashes = @(Get-Content -LiteralPath $checksums | ForEach-Object { if ($_ -cmatch $pattern) { $Matches[1] } }) if ($hashes.Count -ne 1) { throw 'Expected exactly one archive checksum in the release manifest.' } Invoke-WebRequest -UseBasicParsing -Uri "$base/$archiveName" -OutFile $archivePath -TimeoutSec 300 if ((Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $hashes[0]) { throw 'Downloaded archive checksum does not match the release manifest.' } Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($archivePath) try { foreach ($entryName in @('terra.exe', 'LICENSE')) { $entries = @($zip.Entries | Where-Object { $_.FullName -ceq $entryName }) if ($entries.Count -ne 1) { throw "Archive must contain exactly one $entryName." } $destination = $entryName if ($entryName -eq 'LICENSE') { $destination = 'terra-LICENSE' } [IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], (Join-Path $stage $destination)) } } finally { $zip.Dispose() } $output = & (Join-Path $stage 'terra.exe') version 2>&1 if ($LASTEXITCODE -ne 0 -or "$output" -notmatch ('^terra version v?' + [regex]::Escape($Version) + '(\s|$)')) { throw "Downloaded executable did not report Terra $Version." } $digest = (Get-FileHash -LiteralPath (Join-Path $stage 'terra.exe') -Algorithm SHA256).Hash.ToLowerInvariant() [IO.File]::WriteAllText((Join-Path $stage '.terra-install'), "terra-standalone-v1`n$Version`n$digest`n") $backup = Join-Path $stage 'backup' [IO.Directory]::CreateDirectory($backup) | Out-Null $saved = @() $installed = @() try { foreach ($name in $names) { $target = Join-Path $InstallDir $name if (Test-Path -LiteralPath $target) { Move-Item -LiteralPath $target -Destination (Join-Path $backup $name) $saved += $name } Move-Item -LiteralPath (Join-Path $stage $name) -Destination $target $installed += $name } } catch { $failure = $_ try { foreach ($name in $installed) { Remove-Item -LiteralPath (Join-Path $InstallDir $name) -Force } foreach ($name in $saved) { Move-Item -LiteralPath (Join-Path $backup $name) -Destination (Join-Path $InstallDir $name) } } catch { $keepStage = $true throw "Rollback failed. Preserve $stage for recovery: $($_.Exception.Message)" } throw "Installation failed; previous files restored. Close running Terra processes and retry. $failure" } if (-not $NoModifyPath) { try { $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $segments = @($userPath -split ';' | Where-Object { $_ -and $_.TrimEnd('\') -ine $InstallDir.TrimEnd('\') }) [Environment]::SetEnvironmentVariable('Path', (@($InstallDir) + $segments -join ';'), 'User') $segments = @($env:Path -split ';' | Where-Object { $_ -and $_.TrimEnd('\') -ine $InstallDir.TrimEnd('\') }) $env:Path = @($InstallDir) + $segments -join ';' } catch { Write-Warning "Terra is installed, but PATH setup failed. Add $InstallDir to your user PATH. $($_.Exception.Message)" } } Write-Host "Terra $Version installed in $InstallDir" if ($NoModifyPath) { Write-Host "Add $InstallDir to PATH, then run terra login." } else { Write-Host 'Run terra login to get started. New terminals will use the updated user PATH.' } } finally { if ($stage -and -not $keepStage) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } if ($lock) { $lock.Dispose() } [Net.ServicePointManager]::SecurityProtocol = $originalProtocol } }