# Installs the Zen Staff CLI from a GitHub Release archive on Windows. Needs # only PowerShell 5.1+; no Node, npm, or compiler. # # irm https://zenstaff.app/install.ps1 | iex (ZEN_CLI_INSTALL_PS1_URL) # powershell -ExecutionPolicy Bypass -File scripts\install.ps1 (from a checkout) # # The command is `zs` (ZEN_CLI_NAME in packages/contracts/src/zen.ts), with the aliases `zen` and # `zenstaff` (ZEN_CLI_ALIASES, the names before 0.2.8). The archive's executable keeps upstream's # name, t3.exe. See ZEN.md "CLI command". # # Environment: # T3CODE_CHANNEL release train to follow: stable, nightly, or preview # (default: stable; preview is a maintainers' test train) # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) # T3CODE_HOME T3 home directory (default: ~\.t3) # T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) # # The archive is unpacked into $T3CODE_HOME\runtime\versions\, the # same layout `zs service install` uses, so the service reuses this download. $ErrorActionPreference = "Stop" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $repo = "ZenStaff/zenstaff" # Zen Staff: the command on PATH and its aliases (kept for installs made before the rename). $cmd = "zs" $cmdAliases = @("zen", "zenstaff") $baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } $t3Home = if ($env:ZEN_HOME) { $env:ZEN_HOME } elseif ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".zen" } $binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } # Absolute, so PATH never gets a folder that changes with the working directory. $binDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($binDir) function Fail([string] $message) { Write-Error "$cmd install: $message" exit 1 } $terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb" $interactive = $terminal -and $Host.UI.SupportsVirtualTerminal if ($interactive) { # Legacy code pages may support ANSI escapes but not the logo or bar glyphs. $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7) $encoding = [Console]::Error.Encoding $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs } $esc = [char]27 $reset = $bold = $muted = $accent = $green = "" if ($interactive -and -not $env:NO_COLOR) { $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m" $accent = "$esc[94m"; $green = "$esc[32m" } function Step([string] $message) { if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") } else { [Console]::Error.WriteLine(" $message") } } function Draw-Download([long] $bytes, [long] $total) { if (-not $interactive) { return } $columns = [Console]::WindowWidth if ($columns -le 0) { $columns = 80 } if ($total -gt 0 -and $columns -ge 9) { $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total)) $width = [Math]::Min(32, $columns - 8) $filled = [int][Math]::Floor($percent * $width / 100) $bar = ([string][char]0x25A0) * $filled $rest = ([string][char]0x00B7) * ($width - $filled) $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB)) if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" } [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset") } else { $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) } [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1))) } } # Waits 1, 2, then 4 s before retrying a 408, 429, 5xx, or network error (status 0). # Retry-After is ignored so a throttled GitHub cannot stall the install for minutes. function Wait-Retry([int] $status, [int] $attempt) { if ($attempt -ge 4 -or ($status -ge 400 -and $status -lt 500 -and $status -ne 408 -and $status -ne 429)) { return $false } Start-Sleep -Seconds ([Math]::Pow(2, $attempt - 1)) return $true } function Invoke-Retrying([scriptblock] $request) { for ($attempt = 1; ; $attempt++) { try { return & $request } catch { if (-not (Wait-Retry ([int]$_.Exception.Response.StatusCode) $attempt)) { throw } } } } function Fetch([string] $uri, [string] $destination, [switch] $progress) { if (-not $progress -or -not $interactive) { $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" } Invoke-Retrying { Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing } return } Add-Type -AssemblyName System.Net.Http $client = New-Object System.Net.Http.HttpClient $response = $source = $file = $null try { for ($attempt = 1; ; $attempt++) { try { $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() } catch { if (Wait-Retry 0 $attempt) { continue } throw } if ($response.IsSuccessStatusCode -or -not (Wait-Retry ([int]$response.StatusCode) $attempt)) { break } $response.Dispose() } $response.EnsureSuccessStatusCode() | Out-Null $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $file = [IO.File]::Create($destination) $buffer = New-Object byte[] 65536 $bytes = 0L $clock = [Diagnostics.Stopwatch]::StartNew() Draw-Download 0 $response.Content.Headers.ContentLength while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { $file.Write($buffer, 0, $count) $bytes += $count if ($clock.ElapsedMilliseconds -ge 100) { Draw-Download $bytes $response.Content.Headers.ContentLength $clock.Restart() } } Draw-Download $bytes $bytes [Console]::Error.WriteLine() } finally { if ($file) { $file.Dispose() } if ($source) { $source.Dispose() } if ($response) { $response.Dispose() } $client.Dispose() } } if ($interactive) { # Block characters are constructed so this file also loads correctly in PowerShell 5.1. $mark = @( "########## ######## ", " ### _##^ ", " ### ####_ ", " ### _ ###", " ### #######^ " ) [Console]::Error.WriteLine() for ($i = 0; $i -lt $mark.Length; $i++) { $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584) $label = if ($i -eq 1) { " ${bold}Zen Staff$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" } [Console]::Error.WriteLine(" $bold$row$reset$label") } [Console]::Error.WriteLine() } Step "Finding your release..." # PROCESSOR_ARCHITEW6432 bypasses WOW64 emulation; 5.1 hosts can lack RuntimeInformation's required .NET version. $rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } $arch = switch ($rawArch) { "AMD64" { "x64" } "ARM64" { "arm64" } default { Fail "unsupported architecture $rawArch" } } $channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } $version = $env:T3CODE_VERSION if (-not $version) { $tagPattern = switch ($channel) { "stable" { '^v\d+\.\d+\.\d+$' } "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } } if ($channel -eq "stable") { # release.yml marks only plain stable releases latest. This is a github.com # page, so it is not cut off by a page of newer nightlies or the API quota. $release = Invoke-Retrying { Invoke-RestMethod -Uri "https://github.com/$repo/releases/latest" -Headers @{ "User-Agent" = "t3-install"; "Accept" = "application/json" } } $tag = ($release | Where-Object { $_.tag_name -match $tagPattern }).tag_name } else { $tag = $null for ($page = 1; -not $tag -and $page -le 10; $page++) { $releases = Invoke-Retrying { Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100&page=$page" -Headers @{ "User-Agent" = "t3-install" } } $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name if (-not $releases) { break } } } if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } $version = $tag.Substring(1) } if ($version -match '-preview\.') { Write-Warning "Zen Staff $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." if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { Fail "refusing a preview build that was not explicitly requested" } } $stem = "t3-$version-win32-$arch" $archive = "$stem.zip" $versionsDir = Join-Path $t3Home "runtime\versions" $targetDir = Join-Path $versionsDir $version $marker = Join-Path $targetDir ".install-complete" if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { Step "Version $version is already downloaded." } else { New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path $staging | Out-Null try { if ($interactive) { [Console]::Error.Write("`r$esc[2K") } [Console]::Error.WriteLine(" ${muted}Installing$reset Zen Staff $bold$version$reset`n") Step "Downloading..." try { Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS") } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 404) { Fail "Zen Staff $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" } throw } Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress Step "Verifying the download..." $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } $expected = ($expected -split "\s+")[0].ToLowerInvariant() $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } Step "Extracting Zen Staff..." # The archive module reads the global preference, not the caller's local scope. $savedProgress = $global:ProgressPreference try { $global:ProgressPreference = "SilentlyContinue" Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force } finally { $global:ProgressPreference = $savedProgress } Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force & (Join-Path $staging "t3.exe") --version | Out-Null if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } Move-Item $staging $targetDir } catch { if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } throw } } Step "Setting up the $cmd command..." New-Item -ItemType Directory -Force -Path $binDir | Out-Null $shim = Join-Path $binDir "$cmd.cmd" # Replace only a shim that runs a version in this home; never the desktop app's or another one. if (Test-Path -LiteralPath $shim) { $existingTarget = "" # The shim is UTF-8 without a BOM; Windows PowerShell 5.1 reads files as ANSI by default. # Only a shim exactly as written here counts; a user's wrapper that also runs t3.exe does not. $existing = [System.IO.File]::ReadAllText($shim, [System.Text.Encoding]::UTF8) if ($existing -match '^@echo off\r?\n"([^"]+)" %\*\s*$') { $existingTarget = $Matches[1] } # Full paths, so a target like versions\..\other cannot pass as ours. $ownedPrefix = [System.IO.Path]::GetFullPath($versionsDir).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar if ($existingTarget) { $existingTarget = [System.IO.Path]::GetFullPath($existingTarget) } # Exactly \t3.exe, never another program inside a version. $owned = $existingTarget.StartsWith($ownedPrefix, [System.StringComparison]::OrdinalIgnoreCase) -and ($existingTarget.Substring($ownedPrefix.Length) -match '^[^\\/]+\\t3\.exe$') if (-not $owned) { Fail "another $cmd is already at $shim. Remove it and run this again, or run $(Join-Path $targetDir 't3.exe') directly." } } # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt # non-ASCII characters in the user's home path. [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) # The aliases get the same shim where nothing else is (an existing file is someone's, or ours). foreach ($aliasName in $cmdAliases) { $aliasShim = Join-Path $binDir "$aliasName.cmd" if (-not (Test-Path -LiteralPath $aliasShim) -or ([System.IO.File]::ReadAllText($aliasShim, [System.Text.Encoding]::UTF8) -match '^@echo off\r?\n"[^"]+\\t3\.exe" %\*\s*$')) { [System.IO.File]::WriteAllText($aliasShim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) } } # Pruning keeps what this shim points at; a background service cannot see $binDir otherwise. $launchers = Join-Path $t3Home "runtime\launchers" $shimPath = (Resolve-Path -LiteralPath $shim).Path if (-not ((Test-Path $launchers) -and ((Get-Content $launchers) -contains $shimPath))) { [System.IO.File]::AppendAllText($launchers, "$shimPath`r`n", (New-Object System.Text.UTF8Encoding $false)) } if ($interactive) { [Console]::Error.Write("`r$esc[2K") } [Console]::Error.WriteLine(" ${green}Installed Zen Staff $version$reset`n") if ($binDir.Contains(";")) { # PATH would split the folder at the `;`, so name the command in full instead. Write-Host " Run ${bold}& '$($shim.Replace("'", "''"))'$reset to get started.`n" } else { # Either side may end in a separator. Trim only here: a drive root keeps its `\` for Join-Path. $added = ($env:PATH -split ";" | ForEach-Object { $_.TrimEnd('\', '/') }) -notcontains $binDir.TrimEnd('\', '/') # The script runs in the user's own session, so the command works in this window right away. # Last, so it never shadows another command of the same name. if ($added) { $env:Path = "$env:Path;$binDir" } # Another command of this name earlier on PATH runs instead, so name this one in full. $first = Get-Command $cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($first -and [System.IO.Path]::GetFullPath($first.Source) -ne [System.IO.Path]::GetFullPath($shimPath)) { Write-Host " Another $cmd at $($first.Source) runs first, so run ${bold}& '$($shim.Replace("'", "''"))'$reset to use this one.`n" } elseif ($added) { Write-Host " Run ${bold}$cmd$reset to get started. For new windows, add $binDir to your PATH.`n" } else { Write-Host " Run ${bold}$cmd$reset to use Zen Staff here, or ${bold}$cmd up$reset to use it from your other devices.`n" } }