1# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs
2# only PowerShell 5.1+; no Node, npm, or compiler.
3#
4# irm https://t3.codes/install.ps1 | iex
5#
6# Environment:
7# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
8# (default: stable; preview is a maintainers' test train)
9# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
10# T3CODE_HOME T3 home directory (default: ~\.t3)
11# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
12# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
13#
14# The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the
15# same layout `t3 service install` uses, so the service reuses this download.
16$ErrorActionPreference = "Stop"
17[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
18
19$repo = "pingdotgg/t3code"
20$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
21$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" }
22$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" }
23
24function Fail([string] $message) {
25 Write-Error "t3 install: $message"
26 exit 1
27}
28
29$terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb"
30$interactive = $terminal -and $Host.UI.SupportsVirtualTerminal
31if ($interactive) {
32 # Legacy code pages may support ANSI escapes but not the logo or bar glyphs.
33 $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7)
34 $encoding = [Console]::Error.Encoding
35 $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs
36}
37$esc = [char]27
38$reset = $bold = $muted = $accent = $green = ""
39if ($interactive -and -not $env:NO_COLOR) {
40 $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m"
41 $accent = "$esc[94m"; $green = "$esc[32m"
42}
43function Step([string] $message) {
44 if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") }
45 else { [Console]::Error.WriteLine(" $message") }
46}
47function Draw-Download([long] $bytes, [long] $total) {
48 if (-not $interactive) { return }
49 $columns = [Console]::WindowWidth
50 if ($columns -le 0) { $columns = 80 }
51 if ($total -gt 0 -and $columns -ge 9) {
52 $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total))
53 $width = [Math]::Min(32, $columns - 8)
54 $filled = [int][Math]::Floor($percent * $width / 100)
55 $bar = ([string][char]0x25A0) * $filled
56 $rest = ([string][char]0x00B7) * ($width - $filled)
57 $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB))
58 if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" }
59 [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset")
60 } else {
61 $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) }
62 [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1)))
63 }
64}
65function Fetch([string] $uri, [string] $destination, [switch] $progress) {
66 if (-not $progress -or -not $interactive) {
67 $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" }
68 Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing
69 return
70 }
71 # Read the response once, displaying actual bytes received at most ten times a second.
72 Add-Type -AssemblyName System.Net.Http
73 $client = New-Object System.Net.Http.HttpClient
74 $response = $source = $file = $null
75 try {
76 $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
77 $response.EnsureSuccessStatusCode() | Out-Null
78 $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
79 $file = [IO.File]::Create($destination)
80 $buffer = New-Object byte[] 65536
81 $bytes = 0L
82 $clock = [Diagnostics.Stopwatch]::StartNew()
83 Draw-Download 0 $response.Content.Headers.ContentLength
84 while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) {
85 $file.Write($buffer, 0, $count)
86 $bytes += $count
87 if ($clock.ElapsedMilliseconds -ge 100) {
88 Draw-Download $bytes $response.Content.Headers.ContentLength
89 $clock.Restart()
90 }
91 }
92 Draw-Download $bytes $bytes
93 [Console]::Error.WriteLine()
94 } finally {
95 if ($file) { $file.Dispose() }
96 if ($source) { $source.Dispose() }
97 if ($response) { $response.Dispose() }
98 $client.Dispose()
99 }
100}
101if ($interactive) {
102 # Block characters are constructed so this file also loads correctly in PowerShell 5.1.
103 $mark = @(
104 "########## ######## ",
105 " ### _##^ ",
106 " ### ####_ ",
107 " ### _ ###",
108 " ### #######^ "
109 )
110 [Console]::Error.WriteLine()
111 for ($i = 0; $i -lt $mark.Length; $i++) {
112 $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584)
113 $label = if ($i -eq 1) { " ${bold}T3 Code$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" }
114 [Console]::Error.WriteLine(" $bold$row$reset$label")
115 }
116 [Console]::Error.WriteLine()
117}
118Step "Finding your release..."
119
120# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell
121# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts
122# may lack.
123$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
124$arch = switch ($rawArch) {
125 "AMD64" { "x64" }
126 "ARM64" { "arm64" }
127 default { Fail "unsupported architecture $rawArch" }
128}
129
130$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
131$version = $env:T3CODE_VERSION
132if (-not $version) {
133 # Tags are v<semver>; the channel is the prerelease identifier, or none for
134 # stable. Only tags of the requested train are considered, so a stable
135 # install can never pick up a nightly or preview build by accident.
136 $tagPattern = switch ($channel) {
137 "stable" { '^v\d+\.\d+\.\d+$' }
138 "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' }
139 "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' }
140 default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" }
141 }
142 $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" }
143 $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name
144 if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" }
145 $version = $tag.Substring(1)
146}
147if ($version -match '-preview\.') {
148 Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build."
149 if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) {
150 Fail "refusing a preview build that was not explicitly requested"
151 }
152}
153
154$stem = "t3-$version-win32-$arch"
155$archive = "$stem.zip"
156$versionsDir = Join-Path $t3Home "runtime\versions"
157$targetDir = Join-Path $versionsDir $version
158$marker = Join-Path $targetDir ".install-complete"
159
160if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
161 Step "Version $version is already downloaded."
162} else {
163 New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null
164 $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName())
165 New-Item -ItemType Directory -Path $staging | Out-Null
166 try {
167 if ($interactive) { [Console]::Error.Write("`r$esc[2K") }
168 [Console]::Error.WriteLine(" ${muted}Installing$reset T3 Code $bold$version$reset`n")
169 Step "Downloading..."
170 try {
171 Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS")
172 } catch {
173 $status = $_.Exception.Response.StatusCode.value__
174 if ($status -eq 404) {
175 Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'"
176 }
177 throw
178 }
179 Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress
180
181 Step "Verifying the download..."
182
183 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
184 if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" }
185 $expected = ($expected -split "\s+")[0].ToLowerInvariant()
186 $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant()
187 if ($actual -ne $expected) { Fail "checksum mismatch for $archive" }
188
189 Step "Extracting T3 Code..."
190 # The archive module reads the global preference, not the caller's local scope.
191 $savedProgress = $global:ProgressPreference
192 try {
193 $global:ProgressPreference = "SilentlyContinue"
194 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force
195 } finally { $global:ProgressPreference = $savedProgress }
196 # The archive wraps everything in one directory named after its stem.
197 Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging
198 Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force
199
200 & (Join-Path $staging "t3.exe") --version | Out-Null
201 if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" }
202 Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline
203
204 if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force }
205 Move-Item $staging $targetDir
206 } catch {
207 if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
208 throw
209 }
210}
211
212Step "Setting up the t3 command..."
213New-Item -ItemType Directory -Force -Path $binDir | Out-Null
214$shim = Join-Path $binDir "t3.cmd"
215# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt
216# non-ASCII characters in the user's home path.
217[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false))
218if ($interactive) { [Console]::Error.Write("`r$esc[2K") }
219[Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n")
220if (($env:PATH -split ";") -notcontains $binDir) {
221 Write-Host " Add $binDir to your PATH, then run ${bold}t3$reset.`n"
222} else {
223 Write-Host " Run ${bold}t3$reset to get started.`n"
224}