install.ps1 RawHistoryFolder
1# zut installer for Windows (PowerShell).
2#
3# Usage (in PowerShell):
4# iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
5#
6# Or with arguments:
7# $env:ZUT_VERSION = "v0.1.0"
8# $env:ZUT_PREFIX = "$HOME\bin"
9# iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex
10#
11# Detects architecture, downloads the matching .zip from the GitHub
12# release, verifies the sha256 against checksums.txt, extracts zut.exe,
13# and moves it into $ZUT_PREFIX (defaults to $HOME\bin, added to PATH
14# via the User environment if missing).
15#
16# $env:GITHUB_TOKEN is optional for the public repo. Set it to a PAT
17# with `contents:read` scope if you hit GitHub API rate limits (or if
18# you are installing from a private fork); the script then uses it for
19# the version lookup and every download.
20
21
22[CmdletBinding()]
23param(
24 [string]$Version = $env:ZUT_VERSION,
25 [string]$Prefix = $env:ZUT_PREFIX
26)
27
28$ErrorActionPreference = "Stop"
29
30$owner = "bnema"
31$repo = "zut"
32$binary = "zut"
33
34# Build Authorization header list once; used on every HTTP call so the
35# script works against private repos when $env:GITHUB_TOKEN is set.
36$headers = @{}
37if ($env:GITHUB_TOKEN) { $headers["Authorization"] = "Bearer $($env:GITHUB_TOKEN)" }
38
39if (-not $Version) { $Version = "latest" }
40if (-not $Prefix) { $Prefix = Join-Path $HOME "bin" }
41
42function Msg($m) { Write-Host "==> $m" -ForegroundColor Cyan }
43function Warn($m) { Write-Warning $m }
44function Die($m) { Write-Error $m; exit 1 }
45
46# ---- detect architecture ----
47
48switch -wildcard ($env:PROCESSOR_ARCHITECTURE) {
49 "AMD64" { $arch = "amd64" }
50 "ARM64" { $arch = "arm64" }
51 default { Die "unsupported arch: $($env:PROCESSOR_ARCHITECTURE)" }
52}
53
54# ARM64 Windows isn't shipped (see .goreleaser.yaml ignore rule) — fall
55# back to amd64 which runs fine under ARM64 emulation.
56if ($arch -eq "arm64") {
57 Warn "windows/arm64 is not published; falling back to amd64"
58 $arch = "amd64"
59}
60
61# ---- resolve version ----
62#
63# Resolve "latest" through the GitHub releases API. This works the same
64# on Windows PowerShell 5.1 and PowerShell 7+, unlike scraping the
65# /releases/latest redirect target: on PS7 the final URL lives at
66# $resp.BaseResponse.RequestMessage.RequestUri while on PS5.1 it is
67# $resp.BaseResponse.ResponseUri, and relying on either breaks on the
68# other runtime. The API returns the tag directly, so there is nothing
69# to scrape.
70
71if ($Version -eq "latest") {
72 $apiUrl = "https://api.github.com/repos/$owner/$repo/releases/latest"
73 # GitHub's API wants a User-Agent; Invoke-RestMethod sets one, but be
74 # explicit so corporate proxies that strip it don't trip a 403.
75 $apiHeaders = @{} + $headers
76 if (-not $apiHeaders.ContainsKey("User-Agent")) { $apiHeaders["User-Agent"] = "zut-installer" }
77 $apiHeaders["Accept"] = "application/vnd.github+json"
78
79 try {
80 $api = Invoke-RestMethod -UseBasicParsing -Headers $apiHeaders -Uri $apiUrl
81 } catch {
82 $status = $null
83 try { $status = [int]$_.Exception.Response.StatusCode } catch {}
84 if ($status -eq 404) {
85 Die "no published release found for $owner/$repo (the repo may have no releases yet)"
86 } elseif ($status -eq 401 -or $status -eq 403) {
87 Die "GitHub API request was rejected ($status). If the repo is private, set `$env:GITHUB_TOKEN to a PAT with contents:read; otherwise you may be rate-limited (try again later or set `$env:GITHUB_TOKEN)."
88 } else {
89 Die "could not resolve latest version: $($_.Exception.Message)"
90 }
91 }
92
93 $Version = $api.tag_name
94 if (-not $Version) {
95 Die "could not resolve latest version: GitHub API returned no tag_name for $owner/$repo"
96 }
97}
98
99if (-not $Version.StartsWith("v")) { $Version = "v$Version" }
100$verNum = $Version.TrimStart("v")
101
102# ---- download + verify + extract ----
103
104$archive = "${binary}_${verNum}_windows_${arch}.zip"
105$baseUrl = "https://github.com/$owner/$repo/releases/download/$Version"
106$archiveUrl = "$baseUrl/$archive"
107$checksumUrl = "$baseUrl/checksums.txt"
108
109$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("zut-install-" + [System.Guid]::NewGuid().ToString("N").Substring(0,8)))
110
111try {
112 Msg "downloading $archive"
113 Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $archiveUrl -OutFile (Join-Path $tmp $archive)
114
115 Msg "verifying checksum"
116 $checksumFile = Join-Path $tmp "checksums.txt"
117 Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $checksumUrl -OutFile $checksumFile
118 $expected = Get-Content -LiteralPath $checksumFile | ForEach-Object {
119 $line = $_.Trim()
120 if ($line) {
121 $parts = $line -split "\s+"
122 if ($parts.Count -ge 2 -and $parts[($parts.Count - 1)] -eq $archive) { $line }
123 }
124 } | Select-Object -First 1
125 if (-not $expected) { Die "no checksum for $archive in checksums.txt" }
126 $expectedHash = ($expected -split "\s+")[0]
127
128 $actualHash = (Get-FileHash -Path (Join-Path $tmp $archive) -Algorithm SHA256).Hash.ToLower()
129 if ($expectedHash.ToLower() -ne $actualHash) {
130 Die "checksum mismatch: expected $expectedHash, got $actualHash"
131 }
132
133 Msg "extracting"
134 Expand-Archive -Path (Join-Path $tmp $archive) -DestinationPath $tmp -Force
135
136 $exe = Join-Path $tmp "$binary.exe"
137 if (-not (Test-Path $exe)) { Die "archive did not contain $binary.exe" }
138
139 Msg "installing to $Prefix\$binary.exe"
140 New-Item -ItemType Directory -Path $Prefix -Force | Out-Null
141 Copy-Item $exe (Join-Path $Prefix "$binary.exe") -Force
142
143 # ---- PATH hint ----
144
145 $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
146 $parts = $userPath -split ";" | Where-Object { $_ }
147 if (-not ($parts -contains $Prefix)) {
148 Warn "$Prefix is not on your user PATH"
149 Warn "adding it for future sessions..."
150 [Environment]::SetEnvironmentVariable("Path", ($userPath.TrimEnd(";") + ";" + $Prefix), "User")
151 Warn "open a new terminal to pick up the change, or run:"
152 Warn " `$env:Path = `"$Prefix;`$env:Path`""
153 }
154
155 Msg "installed. run: zut --help"
156}
157finally {
158 Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
159}