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# Absolute, so PATH never gets a folder that changes with the working directory.
24$binDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($binDir)
25
26function Fail([string] $message) {
27 Write-Error "t3 install: $message"
28 exit 1
29}
30
31$terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb"
32$interactive = $terminal -and $Host.UI.SupportsVirtualTerminal
33if ($interactive) {
34 # Legacy code pages may support ANSI escapes but not the logo or bar glyphs.
35 $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7)
36 $encoding = [Console]::Error.Encoding
37 $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs
38}
39$esc = [char]27
40$reset = $bold = $muted = $accent = $green = ""
41if ($interactive -and -not $env:NO_COLOR) {
42 $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m"
43 $accent = "$esc[94m"; $green = "$esc[32m"
44}
45function Step([string] $message) {
46 if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") }
47 else { [Console]::Error.WriteLine(" $message") }
48}
49function Draw-Download([long] $bytes, [long] $total) {
50 if (-not $interactive) { return }
51 $columns = [Console]::WindowWidth
52 if ($columns -le 0) { $columns = 80 }
53 if ($total -gt 0 -and $columns -ge 9) {
54 $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total))
55 $width = [Math]::Min(32, $columns - 8)
56 $filled = [int][Math]::Floor($percent * $width / 100)
57 $bar = ([string][char]0x25A0) * $filled
58 $rest = ([string][char]0x00B7) * ($width - $filled)
59 $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB))
60 if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" }
61 [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset")
62 } else {
63 $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) }
64 [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1)))
65 }
66}
67# Waits 1, 2, then 4 s before retrying a 408, 429, 5xx, or network error (status 0).
68# Retry-After is ignored so a throttled GitHub cannot stall the install for minutes.
69function Wait-Retry([int] $status, [int] $attempt) {
70 if ($attempt -ge 4 -or ($status -ge 400 -and $status -lt 500 -and $status -ne 408 -and $status -ne 429)) { return $false }
71 Start-Sleep -Seconds ([Math]::Pow(2, $attempt - 1))
72 return $true
73}
74function Invoke-Retrying([scriptblock] $request) {
75 for ($attempt = 1; ; $attempt++) {
76 try { return & $request }
77 catch { if (-not (Wait-Retry ([int]$_.Exception.Response.StatusCode) $attempt)) { throw } }
78 }
79}
80function Fetch([string] $uri, [string] $destination, [switch] $progress) {
81 if (-not $progress -or -not $interactive) {
82 $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" }
83 Invoke-Retrying { Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing }
84 return
85 }
86 Add-Type -AssemblyName System.Net.Http
87 $client = New-Object System.Net.Http.HttpClient
88 $response = $source = $file = $null
89 try {
90 for ($attempt = 1; ; $attempt++) {
91 try { $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() }
92 catch { if (Wait-Retry 0 $attempt) { continue } throw }
93 if ($response.IsSuccessStatusCode -or -not (Wait-Retry ([int]$response.StatusCode) $attempt)) { break }
94 $response.Dispose()
95 }
96 $response.EnsureSuccessStatusCode() | Out-Null
97 $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
98 $file = [IO.File]::Create($destination)
99 $buffer = New-Object byte[] 65536
100 $bytes = 0L
101 $clock = [Diagnostics.Stopwatch]::StartNew()
102 Draw-Download 0 $response.Content.Headers.ContentLength
103 while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) {
104 $file.Write($buffer, 0, $count)
105 $bytes += $count
106 if ($clock.ElapsedMilliseconds -ge 100) {
107 Draw-Download $bytes $response.Content.Headers.ContentLength
108 $clock.Restart()
109 }
110 }
111 Draw-Download $bytes $bytes
112 [Console]::Error.WriteLine()
113 } finally {
114 if ($file) { $file.Dispose() }
115 if ($source) { $source.Dispose() }
116 if ($response) { $response.Dispose() }
117 $client.Dispose()
118 }
119}
120if ($interactive) {
121 # Block characters are constructed so this file also loads correctly in PowerShell 5.1.
122 $mark = @(
123 "########## ######## ",
124 " ### _##^ ",
125 " ### ####_ ",
126 " ### _ ###",
127 " ### #######^ "
128 )
129 [Console]::Error.WriteLine()
130 for ($i = 0; $i -lt $mark.Length; $i++) {
131 $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584)
132 $label = if ($i -eq 1) { " ${bold}T3 Code$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" }
133 [Console]::Error.WriteLine(" $bold$row$reset$label")
134 }
135 [Console]::Error.WriteLine()
136}
137Step "Finding your release..."
138
139# PROCESSOR_ARCHITEW6432 bypasses WOW64 emulation; 5.1 hosts can lack RuntimeInformation's required .NET version.
140$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
141$arch = switch ($rawArch) {
142 "AMD64" { "x64" }
143 "ARM64" { "arm64" }
144 default { Fail "unsupported architecture $rawArch" }
145}
146
147$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
148$version = $env:T3CODE_VERSION
149if (-not $version) {
150 $tagPattern = switch ($channel) {
151 "stable" { '^v\d+\.\d+\.\d+$' }
152 "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' }
153 "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' }
154 default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" }
155 }
156 if ($channel -eq "stable") {
157 # release.yml marks only plain stable releases latest. This is a github.com
158 # page, so it is not cut off by a page of newer nightlies or the API quota.
159 $release = Invoke-Retrying { Invoke-RestMethod -Uri "https://github.com/$repo/releases/latest" -Headers @{ "User-Agent" = "t3-install"; "Accept" = "application/json" } }
160 $tag = ($release | Where-Object { $_.tag_name -match $tagPattern }).tag_name
161 } else {
162 $tag = $null
163 for ($page = 1; -not $tag -and $page -le 10; $page++) {
164 $releases = Invoke-Retrying { Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100&page=$page" -Headers @{ "User-Agent" = "t3-install" } }
165 $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name
166 if (-not $releases) { break }
167 }
168 }
169 if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" }
170 $version = $tag.Substring(1)
171}
172if ($version -match '-preview\.') {
173 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."
174 if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) {
175 Fail "refusing a preview build that was not explicitly requested"
176 }
177}
178
179$stem = "t3-$version-win32-$arch"
180$archive = "$stem.zip"
181$versionsDir = Join-Path $t3Home "runtime\versions"
182$targetDir = Join-Path $versionsDir $version
183$marker = Join-Path $targetDir ".install-complete"
184
185if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
186 Step "Version $version is already downloaded."
187} else {
188 New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null
189 $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName())
190 New-Item -ItemType Directory -Path $staging | Out-Null
191 try {
192 if ($interactive) { [Console]::Error.Write("`r$esc[2K") }
193 [Console]::Error.WriteLine(" ${muted}Installing$reset T3 Code $bold$version$reset`n")
194 Step "Downloading..."
195 try {
196 Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS")
197 } catch {
198 $status = $_.Exception.Response.StatusCode.value__
199 if ($status -eq 404) {
200 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'"
201 }
202 throw
203 }
204 Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress
205
206 Step "Verifying the download..."
207
208 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
209 if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" }
210 $expected = ($expected -split "\s+")[0].ToLowerInvariant()
211 $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant()
212 if ($actual -ne $expected) { Fail "checksum mismatch for $archive" }
213
214 Step "Extracting T3 Code..."
215 # The archive module reads the global preference, not the caller's local scope.
216 $savedProgress = $global:ProgressPreference
217 try {
218 $global:ProgressPreference = "SilentlyContinue"
219 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force
220 } finally { $global:ProgressPreference = $savedProgress }
221 Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging
222 Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force
223
224 & (Join-Path $staging "t3.exe") --version | Out-Null
225 if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" }
226 Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline
227
228 if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force }
229 Move-Item $staging $targetDir
230 } catch {
231 if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
232 throw
233 }
234}
235
236Step "Setting up the t3 command..."
237New-Item -ItemType Directory -Force -Path $binDir | Out-Null
238$shim = Join-Path $binDir "t3.cmd"
239# Replace only a shim that runs a version in this home; never the desktop app's or another t3.
240if (Test-Path -LiteralPath $shim) {
241 $existingTarget = ""
242 # The shim is UTF-8 without a BOM; Windows PowerShell 5.1 reads files as ANSI by default.
243 # Only a shim exactly as written here counts; a user's wrapper that also runs t3.exe does not.
244 $existing = [System.IO.File]::ReadAllText($shim, [System.Text.Encoding]::UTF8)
245 if ($existing -match '^@echo off\r?\n"([^"]+)" %\*\s*$') { $existingTarget = $Matches[1] }
246 # Full paths, so a target like versions\..\other cannot pass as ours.
247 $ownedPrefix = [System.IO.Path]::GetFullPath($versionsDir).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
248 if ($existingTarget) { $existingTarget = [System.IO.Path]::GetFullPath($existingTarget) }
249 # Exactly <version>\t3.exe, never another program inside a version.
250 $owned = $existingTarget.StartsWith($ownedPrefix, [System.StringComparison]::OrdinalIgnoreCase) -and
251 ($existingTarget.Substring($ownedPrefix.Length) -match '^[^\\/]+\\t3\.exe$')
252 if (-not $owned) {
253 Fail "another t3 is already at $shim. Remove it and run this again, or run $(Join-Path $targetDir 't3.exe') directly."
254 }
255}
256# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt
257# non-ASCII characters in the user's home path.
258[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false))
259# Pruning keeps what this shim points at; a background service cannot see $binDir otherwise.
260$launchers = Join-Path $t3Home "runtime\launchers"
261$shimPath = (Resolve-Path -LiteralPath $shim).Path
262if (-not ((Test-Path $launchers) -and ((Get-Content $launchers) -contains $shimPath))) {
263 [System.IO.File]::AppendAllText($launchers, "$shimPath`r`n", (New-Object System.Text.UTF8Encoding $false))
264}
265if ($interactive) { [Console]::Error.Write("`r$esc[2K") }
266[Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n")
267if ($binDir.Contains(";")) {
268 # PATH would split the folder at the `;`, so name the command in full instead.
269 Write-Host " Run ${bold}& '$($shim.Replace("'", "''"))'$reset to get started.`n"
270} else {
271 # Either side may end in a separator. Trim only here: a drive root keeps its `\` for Join-Path.
272 $added = ($env:PATH -split ";" | ForEach-Object { $_.TrimEnd('\', '/') }) -notcontains $binDir.TrimEnd('\', '/')
273 # `irm | iex` runs in the user's own session, so t3 works in this window right away.
274 # Last, so it never shadows another t3.
275 if ($added) { $env:Path = "$env:Path;$binDir" }
276 # A t3 earlier on PATH runs instead, so name this one in full.
277 $first = Get-Command t3 -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
278 if ($first -and [System.IO.Path]::GetFullPath($first.Source) -ne [System.IO.Path]::GetFullPath($shimPath)) {
279 Write-Host " Another t3 at $($first.Source) runs first, so run ${bold}& '$($shim.Replace("'", "''"))'$reset to use this one.`n"
280 } elseif ($added) {
281 Write-Host " Run ${bold}t3$reset to get started. For new windows, add $binDir to your PATH.`n"
282 } else {
283 Write-Host " Run ${bold}t3$reset to use T3 Code here, or ${bold}t3 up$reset to use it from your other devices.`n"
284 }
285}