diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml
new file mode 100644
index 000000000..7e5b36bb2
--- /dev/null
+++ b/.github/workflows/install-scripts.yml
@@ -0,0 +1,58 @@
+name: Install Scripts
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/install-scripts.yml"
+ - "packages/cli/install.ps1"
+ pull_request:
+ paths:
+ - ".github/workflows/install-scripts.yml"
+ - "packages/cli/install.ps1"
+
+jobs:
+ windows-powershell:
+ name: ${{ matrix.shell }} installs ${{ matrix.version || 'latest' }}
+ runs-on: windows-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ # pwsh is PowerShell 7; powershell is the Windows PowerShell 5.1 that
+ # ships with Windows. `version` empty means latest stable.
+ shell: [pwsh, powershell]
+ version: [""]
+ include:
+ # A legacy getsentry/cli release, resolved through the registry.
+ - shell: pwsh
+ version: "0.42.2"
+ defaults:
+ run:
+ shell: ${{ matrix.shell }}
+ env:
+ SENTRY_CLI_NO_TELEMETRY: "1"
+ SENTRY_VERSION: ${{ matrix.version }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install through Invoke-Expression
+ run: |
+ $env:SENTRY_INSTALL_DIR = Join-Path $env:RUNNER_TEMP 'sentry-bin'
+ Get-Content -Raw -LiteralPath packages/cli/install.ps1 | Invoke-Expression
+ $installed = & (Join-Path $env:SENTRY_INSTALL_DIR 'sentry.exe') --version
+ if ($LASTEXITCODE -ne 0) { throw "sentry --version exited with $LASTEXITCODE" }
+ if ($env:SENTRY_VERSION -and "$installed".Trim() -ne $env:SENTRY_VERSION) {
+ throw "Installed $installed, expected $env:SENTRY_VERSION"
+ }
+ "Installed sentry $installed"
+
+ - name: Find sentry on PATH in a later step
+ run: |
+ $command = Get-Command sentry -ErrorAction Stop
+ "Resolved $($command.Source)"
+ sentry --version
+ if ($LASTEXITCODE -ne 0) { throw "sentry --version exited with $LASTEXITCODE" }
diff --git a/apps/cli-docs/public/install.ps1 b/apps/cli-docs/public/install.ps1
new file mode 120000
index 000000000..51c0be15e
--- /dev/null
+++ b/apps/cli-docs/public/install.ps1
@@ -0,0 +1 @@
+../../../packages/cli/install.ps1
\ No newline at end of file
diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx
index b9dc05975..d7144b97a 100644
--- a/apps/cli-docs/src/content/docs/getting-started.mdx
+++ b/apps/cli-docs/src/content/docs/getting-started.mdx
@@ -55,6 +55,26 @@ You can also set `SENTRY_INSTALL_DIR` to override the binary installation direct
curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin" bash
```
+### Windows (PowerShell)
+
+On Windows, install the latest stable release from PowerShell:
+
+```powershell
+irm https://cli.sentry.dev/install.ps1 | iex
+```
+
+The PowerShell installer adds the install directory to your user `PATH`. It
+reads the same `SENTRY_VERSION`, `SENTRY_INSTALL_DIR`, and `SENTRY_INIT`
+environment variables as the install script. Set `SENTRY_NO_MODIFY_PATH=1` to
+leave `PATH` unchanged:
+
+```powershell
+$env:SENTRY_VERSION = "0.42.2"; irm https://cli.sentry.dev/install.ps1 | iex
+```
+
+It installs stable releases only. To switch to nightly afterwards, run
+`sentry cli upgrade nightly`.
+
### Supported Platforms
{/* GENERATED:START platform-support */}
@@ -81,7 +101,7 @@ curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin"
| Windows |
x64 |
- Via Git Bash, MSYS2, or WSL |
+ Via PowerShell, Git Bash, MSYS2, or WSL |
diff --git a/apps/cli-docs/src/content/docs/index.mdx b/apps/cli-docs/src/content/docs/index.mdx
index 4fbff5831..4711c8c3d 100644
--- a/apps/cli-docs/src/content/docs/index.mdx
+++ b/apps/cli-docs/src/content/docs/index.mdx
@@ -23,6 +23,7 @@ import sectionBg3 from '../../assets/section-bg-3.png';
&2
+ registry_path="$version"
+ else
+ registry_path="latest"
+ fi
+
+ asset="sentry-${os}-${arch}${suffix}"
+ gz_url=""
+ gz_sha=""
+ raw_url=""
+ raw_sha=""
+ registry_version=""
+ registry_status=$(curl -sS --max-time 10 -o "$metadata_file" -w '%{http_code}' \
+ "https://release-registry.services.sentry.io/apps/sentry/${registry_path}" 2>/dev/null) \
+ || registry_status="000"
+ if [[ "$registry_status" == "200" ]]; then
+ # Asset names, URLs, and checksums contain no whitespace, so stripping it
+ # lets the parser below treat compact and pretty-printed JSON the same.
+ metadata=$(tr -d ' \t\r\n' < "$metadata_file")
+ registry_version=$(printf '%s\n' "$metadata" \
+ | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
+ if [[ "$registry_path" == "latest" && ! "$registry_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ registry_version=""
+ elif [[ "$registry_path" != "latest" && "$registry_version" != "$version" ]]; then
+ registry_version=""
+ fi
+ fi
+
+ if [[ -n "$registry_version" ]]; then
+ version="$registry_version"
+ # Print the url and sha256 of one `files` entry, one per line.
+ registry_file() {
+ printf '%s\n' "$metadata" | awk -v key="\"$1\":{" '
+ {
+ i = index($0, key)
+ if (!i) exit
+ s = substr($0, i + length(key) - 1)
+ n = length(s)
+ depth = 0
+ for (j = 1; j <= n; j++) {
+ c = substr(s, j, 1)
+ if (c == "{") depth++
+ else if (c == "}" && --depth == 0) break
+ }
+ obj = substr(s, 1, j)
+ url = ""
+ sha = ""
+ if (match(obj, /"url":"https:\/\/[^"]*"/)) url = substr(obj, RSTART + 7, RLENGTH - 8)
+ if (match(obj, /"sha256-hex":"[0-9a-f]+"/)) sha = substr(obj, RSTART + 14, RLENGTH - 15)
+ print url
+ print sha
+ }'
+ }
+ { read -r gz_url || true; read -r gz_sha || true; } <<< "$(registry_file "${asset}.gz")"
+ { read -r raw_url || true; read -r raw_sha || true; } <<< "$(registry_file "$asset")"
+ if [[ -z "$gz_url" && -z "$raw_url" ]]; then
+ die "Sentry CLI ${version} has no ${asset} build" "resolve-asset"
+ fi
+ elif [[ "$registry_status" == "404" && "$registry_path" != "latest" ]]; then
+ die "Unknown Sentry CLI version: ${version}" "resolve-version"
+ else
+ echo -e "${MUTED}Could not resolve ${registry_path} from Sentry's release registry. Trying GitHub...${NC}" >&2
+ if [[ "$registry_path" == "latest" ]]; then
release_tag=""
- # GitHub may have many pages of MCP releases before its newest CLI release.
for page in {1..20}; do
if ! releases=$(curl -fsSL "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=${page}"); then
die "Failed to list Toolkit releases" "gh-version"
@@ -337,65 +397,45 @@ else
fi
done
if [[ -z "$release_tag" ]]; then
- if [[ "$releases" != "[]" ]]; then
- die "Toolkit release pagination exceeded 20 pages" "gh-version"
- fi
- # Before the first Toolkit CLI release, use the legacy version only
- # after checking whether that exact Toolkit tag exists.
- if ! legacy_release=$(curl -fsSL 'https://api.github.com/repos/getsentry/cli/releases/latest'); then
- die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version"
- fi
- release_tag=$(printf '%s\n' "$legacy_release" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
- if [[ ! "${release_tag#v}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version"
- fi
+ die "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or install a specific version with --version ." "resolve-version"
fi
version="${release_tag#cli@}"
- version="${version#v}"
fi
- else
- version="$requested_version"
- version="${version#cli@}"
- version="${version#v}"
- fi
-
- if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
- die "Invalid release version: ${version}" "gh-version"
+ gz_url="https://github.com/getsentry/toolkit/releases/download/cli@${version}/${asset}.gz"
+ raw_url="${gz_url%.gz}"
+ echo -e "${MUTED}Warning: GitHub fallback downloads are not checksum-verified.${NC}" >&2
fi
- # Fall back to legacy only when GitHub confirms the exact Toolkit tag is absent.
- if ! release_status=$(curl -sS -o /dev/null -w '%{http_code}' \
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%40${version}"); then
- die "Failed to check Toolkit release" "gh-version"
- fi
- case "$release_status" in
- 200) release_repo="getsentry/toolkit"; release_tag="cli@${version}" ;;
- 404)
- if ! legacy_status=$(curl -sS -o /dev/null -w '%{http_code}' \
- "https://api.github.com/repos/getsentry/cli/releases/tags/${version}"); then
- die "Failed to check legacy CLI release" "gh-version"
- fi
- if [[ "$legacy_status" != "200" ]]; then
- die "Legacy CLI release unavailable (HTTP ${legacy_status})" "gh-version"
- fi
- release_repo="getsentry/cli"
- release_tag="$version"
- ;;
- *) die "Toolkit release check failed (HTTP ${release_status})" "gh-version" ;;
- esac
-
- filename="sentry-${os}-${arch}${suffix}"
- url="https://github.com/${release_repo}/releases/download/${release_tag}/${filename}"
+ # Usage: verify_sha256 ; a missing checksum is a no-op.
+ verify_sha256() {
+ [[ -z "$2" ]] && return 0
+ local actual
+ if command -v sha256sum >/dev/null 2>&1; then
+ actual=$(sha256sum "$1" | awk '{print $1}')
+ elif command -v shasum >/dev/null 2>&1; then
+ actual=$(shasum -a 256 "$1" | awk '{print $1}')
+ else
+ echo -e "${MUTED}Warning: sha256sum and shasum are unavailable; skipping checksum verification.${NC}" >&2
+ return 0
+ fi
+ if [[ "$actual" != "$2" ]]; then
+ die "Checksum mismatch for ${asset} ${version}: expected $2, got ${actual}" "checksum"
+ fi
+ }
echo -e "${MUTED}Downloading sentry v${version}...${NC}"
- # Try gzip-compressed download first (~60% smaller, ~37 MB vs ~99 MB).
- # gunzip is POSIX and available on all Unix systems.
- # Falls back to raw binary if the .gz asset doesn't exist yet.
- if curl -fsSL "${url}.gz" 2>/dev/null | gunzip > "$tmp_binary" 2>/dev/null; then
- : # Compressed download succeeded
+ # Prefer the gzip asset (~60% smaller, ~37 MB vs ~99 MB); fall back to the
+ # raw binary when it is missing.
+ if [[ -n "$gz_url" ]] && curl -fsSL -o "$blob_file" "$gz_url" 2>/dev/null; then
+ verify_sha256 "$blob_file" "$gz_sha"
+ gunzip -c "$blob_file" > "$tmp_binary"
+ elif [[ -n "$raw_url" ]]; then
+ curl -fsSL --progress-bar -o "$tmp_binary" "$raw_url" \
+ || die "Failed to download ${raw_url}" "download"
+ verify_sha256 "$tmp_binary" "$raw_sha"
else
- curl -fsSL --progress-bar "$url" -o "$tmp_binary"
+ die "Failed to download ${gz_url}" "download"
fi
fi
diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1
new file mode 100644
index 000000000..6056eb638
--- /dev/null
+++ b/packages/cli/install.ps1
@@ -0,0 +1,349 @@
+<#
+.SYNOPSIS
+ Sentry CLI installer for Windows PowerShell and PowerShell 7+.
+
+.DESCRIPTION
+ Downloads the standalone Sentry CLI binary for Windows and hands off to
+ `sentry cli setup`, which places the binary, installs agent skills, and
+ records the install method so `sentry cli upgrade` keeps working.
+
+ Usage:
+ irm https://cli.sentry.dev/install.ps1 | iex
+
+ Environment variables:
+ SENTRY_VERSION Install a specific stable version (e.g., 0.42.2)
+ SENTRY_INSTALL_DIR Override the installation directory
+ SENTRY_INIT Set to 1 to run `sentry init` after installing
+ SENTRY_NO_MODIFY_PATH Set to 1 to leave the user PATH unchanged
+ SENTRY_CLI_NO_TELEMETRY Set to 1 to disable installer error reporting
+
+ For the nightly channel, install stable first and then run
+ `sentry cli upgrade nightly`.
+#>
+
+# Everything runs inside a function so `irm ... | iex` never changes the
+# caller's session preferences, and failures throw instead of calling `exit`
+# (which would close the user's terminal when run through iex).
+function Install-SentryCli {
+ $ErrorActionPreference = 'Stop'
+ # Invoke-WebRequest progress rendering makes downloads dramatically slower
+ # on Windows PowerShell 5.1.
+ $ProgressPreference = 'SilentlyContinue'
+
+ # Windows PowerShell 5.1 may default to TLS 1.0/1.1, which GitHub rejects.
+ try {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+ } catch {
+ Write-Verbose "Could not enable TLS 1.2: $_"
+ }
+
+ $state = @{ Step = 'start'; Version = 'unknown'; Arch = 'unknown' }
+
+ # Fire-and-forget error report via the envelope API, mirroring the bash
+ # installer. Uses the CLI's public write-only DSN. No PII collected.
+ function Send-InstallError([string]$Message) {
+ if ($env:SENTRY_CLI_NO_TELEMETRY -eq '1') { return }
+ try {
+ $dsnKey = '1188a86f3f8168f089450587b00bca66'
+ $projectId = '4510776311808000'
+ $eventId = [guid]::NewGuid().ToString('N')
+ $header = @{
+ event_id = $eventId
+ dsn = "https://$dsnKey@o1.ingest.us.sentry.io/$projectId"
+ } | ConvertTo-Json -Compress
+ $sentryEvent = @{
+ event_id = $eventId
+ timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
+ platform = 'other'
+ level = 'error'
+ logger = 'install'
+ server_name = 'install-script'
+ message = @{ formatted = $Message }
+ tags = @{
+ os = 'windows'
+ arch = $state.Arch
+ channel = 'stable'
+ step = $state.Step
+ 'install.version' = $state.Version
+ }
+ contexts = @{
+ runtime = @{ name = 'powershell'; version = "$($PSVersionTable.PSVersion)" }
+ }
+ } | ConvertTo-Json -Compress -Depth 5
+ $envelope = "$header`n{`"type`":`"event`"}`n$sentryEvent"
+ Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 2 `
+ -Uri "https://o1.ingest.us.sentry.io/api/$projectId/envelope/" `
+ -ContentType 'application/x-sentry-envelope' `
+ -Headers @{ 'X-Sentry-Auth' = "Sentry sentry_key=$dsnKey,sentry_version=7" } `
+ -Body $envelope | Out-Null
+ } catch {
+ Write-Verbose "Failed to report installer error: $_"
+ }
+ }
+
+ function Stop-Install([string]$Message) {
+ Send-InstallError $Message
+ throw $Message
+ }
+
+ # Returns the HTTP status of a failed web request, or 0 without a response.
+ function Get-ErrorStatus($ErrorRecord) {
+ $response = $ErrorRecord.Exception.Response
+ if ($null -ne $response -and $null -ne $response.StatusCode) {
+ return [int]$response.StatusCode
+ }
+ return 0
+ }
+
+ # Fetch release metadata (download URLs and checksums) from Sentry's release
+ # registry. Returns $null when the registry is unreachable or returns
+ # something unusable; stops when a pinned version does not exist.
+ function Get-RegistryRelease([string]$Path) {
+ try {
+ $metadata = Invoke-RestMethod -UseBasicParsing -TimeoutSec 10 `
+ -Uri "https://release-registry.services.sentry.io/apps/sentry/$Path"
+ } catch {
+ if ((Get-ErrorStatus $_) -eq 404 -and $Path -ne 'latest') {
+ Stop-Install "Unknown Sentry CLI version: $Path"
+ }
+ Write-Verbose "Release registry lookup failed: $_"
+ return $null
+ }
+ $registryVersion = "$($metadata.version)"
+ if ($Path -eq 'latest' -and $registryVersion -notmatch '^\d+\.\d+\.\d+$') { return $null }
+ if ($Path -ne 'latest' -and $registryVersion -ne $Path) { return $null }
+ return $metadata
+ }
+
+ # Returns @{ Url; Sha256 } for one registry file entry, or $null.
+ function Get-RegistryFile($Metadata, [string]$Name) {
+ $entry = $Metadata.files.PSObject.Properties[$Name]
+ if ($null -eq $entry -or "$($entry.Value.url)" -notmatch '^https://') { return $null }
+ return @{ Url = "$($entry.Value.url)"; Sha256 = "$($entry.Value.checksums.'sha256-hex')" }
+ }
+
+ # Usage: Assert-Sha256 ; a missing checksum is a no-op.
+ function Assert-Sha256([string]$Path, [string]$Expected) {
+ if (-not $Expected) { return }
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant()
+ if ($actual -ne $Expected.ToLowerInvariant()) {
+ Stop-Install "Checksum mismatch for $asset ${version}: expected $Expected, got $actual"
+ }
+ }
+
+ # Registry outage fallback: the newest `cli@x.y.z` Toolkit release.
+ function Find-LatestToolkitVersion {
+ # GitHub may have many pages of MCP releases before its newest CLI release.
+ for ($page = 1; $page -le 20; $page++) {
+ try {
+ # Windows PowerShell 5.1 emits a JSON array as one object; piping
+ # through ForEach-Object flattens it so an empty page has no items.
+ $releases = @(Invoke-RestMethod -UseBasicParsing -TimeoutSec 30 `
+ -Uri "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=$page" |
+ ForEach-Object { $_ })
+ } catch {
+ Stop-Install 'Failed to list Toolkit releases'
+ }
+ if ($releases.Count -eq 0) { break }
+ foreach ($release in $releases) {
+ if (-not $release.prerelease -and "$($release.tag_name)" -match '^cli@\d+\.\d+\.\d+$') {
+ return "$($release.tag_name)".Substring(4)
+ }
+ }
+ }
+ Stop-Install "Failed to fetch latest stable version from Sentry's release registry and GitHub. Retry or set SENTRY_VERSION to install a specific version."
+ }
+
+ # Compute the directory `sentry cli setup` will install into, matching
+ # determineInstallDir() in src/lib/binary.ts, so the installer and setup agree
+ # on which directory to put on PATH.
+ function Resolve-InstallDir([string]$HomeDir) {
+ if ($env:SENTRY_INSTALL_DIR) { return $env:SENTRY_INSTALL_DIR }
+ if ($env:XDG_BIN_HOME -and [System.IO.Path]::IsPathRooted($env:XDG_BIN_HOME)) {
+ return $env:XDG_BIN_HOME
+ }
+ $pathDirs = "$env:PATH" -split [System.IO.Path]::PathSeparator |
+ ForEach-Object { $_.TrimEnd('\', '/') }
+ foreach ($dir in @((Join-Path $HomeDir '.local\bin'), (Join-Path $HomeDir 'bin'))) {
+ if ((Test-Path -LiteralPath $dir -PathType Container) -and ($pathDirs -contains $dir.TrimEnd('\', '/'))) {
+ return $dir
+ }
+ }
+ return (Join-Path $HomeDir '.local\bin')
+ }
+
+ # Persist $Dir on the user PATH without expanding existing %VAR% entries.
+ function Add-ToUserPath([string]$Dir) {
+ $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
+ try {
+ $current = "$($key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames))"
+ $entries = @($current -split ';' | Where-Object { $_ })
+ if ($entries | Where-Object { $_.TrimEnd('\') -eq $Dir.TrimEnd('\') }) {
+ return $false
+ }
+ $key.SetValue('Path', (@($Dir) + $entries) -join ';', [Microsoft.Win32.RegistryValueKind]::ExpandString)
+ } finally {
+ $key.Dispose()
+ }
+ # Setting any user variable through .NET broadcasts WM_SETTINGCHANGE, so
+ # newly opened terminals pick up the updated PATH.
+ [Environment]::SetEnvironmentVariable('SENTRY_CLI_PATH_REFRESH', '1', 'User')
+ [Environment]::SetEnvironmentVariable('SENTRY_CLI_PATH_REFRESH', $null, 'User')
+ return $true
+ }
+
+ $state.Step = 'detect-os'
+ if ($env:OS -ne 'Windows_NT') {
+ Stop-Install 'This installer is for Windows. On macOS and Linux, run: curl https://cli.sentry.dev/install -fsS | bash'
+ }
+
+ $state.Step = 'detect-arch'
+ # 32-bit PowerShell on 64-bit Windows reports x86 here and sets
+ # PROCESSOR_ARCHITEW6432 to the real architecture.
+ $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
+ $state.Arch = "$arch"
+ if ($arch -ne 'AMD64') {
+ Stop-Install "Unsupported: windows-$arch (only windows-x64 is supported). Install Node.js and run: npm install -g sentry"
+ }
+ $state.Arch = 'x64'
+
+ # Sentry's release registry is the source of truth for versions, download
+ # URLs (including legacy getsentry/cli releases), and checksums. GitHub is
+ # only a fallback for registry outages.
+ $state.Step = 'resolve-version'
+ if ($env:SENTRY_VERSION -eq 'nightly') {
+ Stop-Install 'The PowerShell installer only installs stable releases. Install stable, then run: sentry cli upgrade nightly'
+ }
+ $registryPath = 'latest'
+ $version = ''
+ if ($env:SENTRY_VERSION) {
+ $version = $env:SENTRY_VERSION -replace '^cli@', '' -replace '^v', ''
+ $state.Version = $version
+ if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') {
+ Stop-Install "Invalid release version: $version"
+ }
+ $registryPath = $version
+ }
+
+ $asset = 'sentry-windows-x64.exe'
+ $metadata = Get-RegistryRelease $registryPath
+ if ($null -ne $metadata) {
+ $version = "$($metadata.version)"
+ $gz = Get-RegistryFile $metadata "$asset.gz"
+ $raw = Get-RegistryFile $metadata $asset
+ if ($null -eq $gz -and $null -eq $raw) {
+ Stop-Install "Sentry CLI $version has no $asset build"
+ }
+ } else {
+ Write-Host "Could not resolve $registryPath from Sentry's release registry. Trying GitHub..." -ForegroundColor DarkGray
+ if (-not $version) { $version = Find-LatestToolkitVersion }
+ $base = "https://github.com/getsentry/toolkit/releases/download/cli@$version/$asset"
+ $gz = @{ Url = "$base.gz"; Sha256 = '' }
+ $raw = @{ Url = $base; Sha256 = '' }
+ Write-Warning 'GitHub fallback downloads are not checksum-verified.'
+ }
+ $state.Version = $version
+
+ $state.Step = 'download'
+ $tmpBinary = Join-Path ([System.IO.Path]::GetTempPath()) "sentry-install-$([guid]::NewGuid().ToString('N')).exe"
+ $tmpArchive = "$tmpBinary.gz"
+
+ Write-Host "Downloading sentry v$version..." -ForegroundColor DarkGray
+ try {
+ # Prefer the gzip asset (~60% smaller); fall back to the raw binary when it
+ # is missing. A checksum mismatch is fatal, never a reason to fall back.
+ $downloaded = $false
+ if ($null -ne $gz) {
+ try {
+ Invoke-WebRequest -UseBasicParsing -Uri $gz.Url -OutFile $tmpArchive
+ $downloaded = $true
+ } catch {
+ Write-Verbose "Compressed download failed, trying raw binary: $_"
+ }
+ }
+ if ($downloaded) {
+ Assert-Sha256 $tmpArchive $gz.Sha256
+ $source = [System.IO.File]::OpenRead($tmpArchive)
+ try {
+ $gzip = New-Object System.IO.Compression.GZipStream($source, [System.IO.Compression.CompressionMode]::Decompress)
+ $target = [System.IO.File]::Create($tmpBinary)
+ try { $gzip.CopyTo($target) } finally { $target.Dispose(); $gzip.Dispose() }
+ } finally {
+ $source.Dispose()
+ }
+ } elseif ($null -ne $raw) {
+ try {
+ Invoke-WebRequest -UseBasicParsing -Uri $raw.Url -OutFile $tmpBinary
+ } catch {
+ Stop-Install "Failed to download $($raw.Url)"
+ }
+ Assert-Sha256 $tmpBinary $raw.Sha256
+ } else {
+ Stop-Install "Failed to download $($gz.Url)"
+ }
+ } catch {
+ Remove-Item -LiteralPath $tmpBinary -Force -ErrorAction SilentlyContinue
+ throw
+ } finally {
+ Remove-Item -LiteralPath $tmpArchive -Force -ErrorAction SilentlyContinue
+ }
+
+ # Resolve the install directory once and pass it to setup explicitly so PATH
+ # handling below targets the same directory setup installs into.
+ $homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
+ $installDir = Resolve-InstallDir $homeDir
+ $previousInstallDir = $env:SENTRY_INSTALL_DIR
+ $env:SENTRY_INSTALL_DIR = $installDir
+
+ try {
+ if ($env:SENTRY_NO_MODIFY_PATH -ne '1') {
+ $state.Step = 'path'
+ try {
+ if (Add-ToUserPath $installDir) {
+ Write-Host "PATH: Added $installDir to your user PATH. Open a new terminal to use it everywhere."
+ }
+ } catch {
+ Write-Warning "Could not update your user PATH. Add $installDir to PATH manually."
+ }
+ # GitHub Actions does not read the registry between steps.
+ if ($env:GITHUB_PATH) {
+ Add-Content -LiteralPath $env:GITHUB_PATH -Value $installDir
+ }
+ # Make the CLI available in this session too.
+ $sessionDirs = "$env:PATH" -split [System.IO.Path]::PathSeparator
+ if ($sessionDirs -notcontains $installDir) {
+ $env:PATH = "$installDir$([System.IO.Path]::PathSeparator)$env:PATH"
+ }
+ }
+
+ # Delegate placement, completions, agent skills, install-method recording,
+ # and first-run login to the binary itself. PATH is handled above because
+ # setup only knows how to edit POSIX shell config files.
+ $state.Step = 'setup'
+ $setupArgs = @('cli', 'setup', '--install', '--method', 'curl', '--channel', 'stable', '--no-modify-path')
+ & $tmpBinary @setupArgs
+ if ($LASTEXITCODE -ne 0) {
+ Stop-Install "sentry cli setup failed with exit code $LASTEXITCODE"
+ }
+
+ if ($env:SENTRY_INIT -eq '1') {
+ $state.Step = 'init'
+ $installedBinary = Join-Path $installDir 'sentry.exe'
+ if (-not (Test-Path -LiteralPath $installedBinary)) {
+ Stop-Install 'Cannot find installed sentry binary'
+ }
+ & $installedBinary init
+ if ($LASTEXITCODE -ne 0) {
+ # Like the bash installer, report wizard failures to the caller but not
+ # to installer telemetry: init errors are not installation failures.
+ throw "sentry init failed with exit code $LASTEXITCODE"
+ }
+ }
+ } finally {
+ $env:SENTRY_INSTALL_DIR = $previousInstallDir
+ # The running temp binary cannot always be deleted on Windows; try anyway.
+ Remove-Item -LiteralPath $tmpBinary -Force -ErrorAction SilentlyContinue
+ }
+}
+
+Install-SentryCli
diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts
index 93e9b54cb..dabba3ad5 100644
--- a/packages/cli/script/generate-docs-sections.ts
+++ b/packages/cli/script/generate-docs-sections.ts
@@ -528,7 +528,7 @@ function generateSelfHostedEnvVarsTable(): string {
const PLATFORM_ROWS: readonly [string, string, string][] = [
["macOS", "x64, arm64 (Apple Silicon)", ""],
["Linux", "x64, arm64", "Standalone binary requires glibc"],
- ["Windows", "x64", "Via Git Bash, MSYS2, or WSL"],
+ ["Windows", "x64", "Via PowerShell, Git Bash, MSYS2, or WSL"],
];
/** Generate the platform support table for getting-started.mdx. */
diff --git a/packages/cli/test/lib/install-ps1-script.test.ts b/packages/cli/test/lib/install-ps1-script.test.ts
new file mode 100644
index 000000000..d6c1c276e
--- /dev/null
+++ b/packages/cli/test/lib/install-ps1-script.test.ts
@@ -0,0 +1,280 @@
+/**
+ * Exercises the PowerShell installer through `Invoke-Expression`, the same
+ * path as `irm https://cli.sentry.dev/install.ps1 | iex`. Network cmdlets are
+ * replaced with PowerShell functions (functions take precedence over cmdlets),
+ * and the downloaded "binary" is a shell script, so the suite runs wherever
+ * `pwsh` is available. It is skipped otherwise.
+ */
+
+import { spawnSync } from "node:child_process";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { gzipSync } from "node:zlib";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import {
+ applyScenario,
+ INSTALL_SCENARIOS,
+ LATEST_VERSION,
+ type ServedAsset,
+ toolkitDownload,
+} from "./install-scenarios.js";
+
+const installScript = join(import.meta.dirname, "..", "..", "install.ps1");
+const hasPwsh =
+ spawnSync("pwsh", ["-NoProfile", "-Command", "exit 0"], { stdio: "ignore" })
+ .status === 0;
+
+const downloadedExecutable = `#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1" == "cli" && "$2" == "setup" ]]; then
+ printf '%s\\n' "$@" > "$SENTRY_TEST_DIR/setup-args"
+ printf '%s\\n' "$SENTRY_INSTALL_DIR" > "$SENTRY_TEST_DIR/setup-install-dir"
+ printf '%s\\n' "$PATH" > "$SENTRY_TEST_DIR/setup-path"
+ mkdir -p "$SENTRY_INSTALL_DIR"
+ cp "$0" "$SENTRY_INSTALL_DIR/sentry.exe"
+ exit "\${SENTRY_TEST_SETUP_EXIT:-0}"
+fi
+printf '%s\\n' "$@" >> "$SENTRY_TEST_DIR/post-args"
+printf '%s\\n' "$0" >> "$SENTRY_TEST_DIR/post-binary"
+exit "\${SENTRY_TEST_POST_EXIT:-0}"
+`;
+
+// Mocks for the cmdlets the installer uses, followed by the iex invocation.
+const harness = String.raw`
+$ErrorActionPreference = 'Stop'
+function Write-Record([string]$Name, [string]$Value) {
+ Add-Content -LiteralPath (Join-Path $env:SENTRY_TEST_DIR $Name) -Value $Value
+}
+function New-HttpError([int]$Status) {
+ $ex = [System.Exception]::new("HTTP $Status")
+ Add-Member -InputObject $ex -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = $Status })
+ return $ex
+}
+function Invoke-RestMethod {
+ param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec)
+ Write-Record 'urls' $Uri
+ if ($Uri -like 'https://release-registry.services.sentry.io/apps/sentry/*') {
+ $status = [int]$env:SENTRY_TEST_REGISTRY_STATUS
+ if ($status -ne 200) { throw (New-HttpError $status) }
+ $version = $Uri.Split('/')[-1]
+ if ($version -eq 'latest') { $version = $env:SENTRY_TEST_LATEST_VERSION }
+ $body = (Get-Content -Raw -LiteralPath $env:SENTRY_TEST_REGISTRY_BODY).Replace('__VERSION__', $version)
+ return ($body | ConvertFrom-Json)
+ }
+ if ($Uri -like 'https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=*') {
+ $page = $Uri.Split('=')[-1]
+ $file = [Environment]::GetEnvironmentVariable("SENTRY_TEST_RELEASES_PAGE_$page")
+ # Like Windows PowerShell 5.1, emit each JSON array as a single object.
+ if ($file) { return ,(Get-Content -Raw -LiteralPath $file | ConvertFrom-Json -NoEnumerate) }
+ return ,@()
+ }
+ throw "Unexpected Invoke-RestMethod $Uri"
+}
+function Invoke-WebRequest {
+ param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec,
+ [string]$Method, $Headers, [string]$ContentType, $Body)
+ Write-Record 'urls' $Uri
+ if ($Uri -like 'https://github.com/*/releases/download/*.gz') {
+ if ($env:SENTRY_TEST_GZ_MISSING -eq '1') { throw (New-HttpError 404) }
+ Copy-Item -LiteralPath $env:SENTRY_TEST_GZ_FILE -Destination $OutFile
+ # The installer decompresses into the existing path; pre-create it
+ # executable so the fake binary can run on POSIX test hosts.
+ $target = $OutFile.Substring(0, $OutFile.Length - 3)
+ New-Item -ItemType File -Path $target -Force | Out-Null
+ chmod +x $target
+ return
+ }
+ if ($Uri -like 'https://github.com/*/releases/download/*') {
+ Copy-Item -LiteralPath $env:SENTRY_TEST_RAW_FILE -Destination $OutFile
+ chmod +x $OutFile
+ return
+ }
+ throw "Unexpected Invoke-WebRequest $Uri"
+}
+Get-Content -Raw -LiteralPath $env:SENTRY_TEST_INSTALL_SCRIPT | Invoke-Expression
+`;
+
+const asset = "sentry-windows-x64.exe";
+const served: ServedAsset = {
+ name: asset,
+ gz: gzipSync(downloadedExecutable),
+ raw: downloadedExecutable,
+};
+
+describe.skipIf(!hasPwsh)("PowerShell install script", () => {
+ let testDir: string;
+ let installDir: string;
+ let env: NodeJS.ProcessEnv;
+ let harnessPath: string;
+
+ beforeEach(() => {
+ testDir = mkdtempSync(join(tmpdir(), "sentry-install-ps1-test-"));
+ installDir = join(testDir, "installed bin");
+ mkdirSync(join(testDir, "home"));
+ harnessPath = join(testDir, "harness.ps1");
+ writeFileSync(harnessPath, harness);
+
+ env = {
+ PATH: process.env.PATH,
+ HOME: join(testDir, "home"),
+ USERPROFILE: join(testDir, "home"),
+ TMPDIR: testDir,
+ OS: "Windows_NT",
+ PROCESSOR_ARCHITECTURE: "AMD64",
+ SENTRY_INSTALL_DIR: installDir,
+ SENTRY_CLI_NO_TELEMETRY: "1",
+ SENTRY_TEST_DIR: testDir,
+ SENTRY_TEST_INSTALL_SCRIPT: installScript,
+ GITHUB_PATH: join(testDir, "github-path"),
+ };
+ applyScenario(
+ { ...INSTALL_SCENARIOS[0], name: "default" },
+ served,
+ testDir,
+ env,
+ );
+ });
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true });
+ });
+
+ function run() {
+ return spawnSync(
+ "pwsh",
+ ["-NoProfile", "-NonInteractive", "-File", harnessPath],
+ { env, encoding: "utf8", timeout: 60_000 },
+ );
+ }
+
+ function recorded(name: string): string[] {
+ const path = join(testDir, name);
+ return existsSync(path)
+ ? readFileSync(path, "utf8").trim().split("\n")
+ : [];
+ }
+
+ test.each(INSTALL_SCENARIOS)("$name", (scenario) => {
+ applyScenario(scenario, served, testDir, env);
+
+ const result = run();
+
+ const output = result.stdout + result.stderr;
+ if (scenario.ok) {
+ expect(result.status, output).toBe(0);
+ expect(existsSync(join(installDir, "sentry.exe"))).toBe(true);
+ } else {
+ expect(result.status, output).not.toBe(0);
+ expect(recorded("setup-args")).toEqual([]);
+ }
+ for (const expected of scenario.output ?? []) {
+ expect(output).toContain(expected);
+ }
+ expect(recorded("urls")).toEqual(scenario.requests(asset));
+ expect(
+ readdirSync(testDir).filter((name) => name.startsWith("sentry-install-")),
+ ).toEqual([]);
+ });
+
+ test("hands off to setup and puts the install dir on PATH", () => {
+ const result = run();
+
+ expect(result.status, result.stdout + result.stderr).toBe(0);
+ expect(recorded("urls").at(-1)).toBe(
+ toolkitDownload(LATEST_VERSION, `${asset}.gz`),
+ );
+ expect(recorded("setup-args")).toEqual([
+ "cli",
+ "setup",
+ "--install",
+ "--method",
+ "curl",
+ "--channel",
+ "stable",
+ "--no-modify-path",
+ ]);
+ expect(recorded("setup-install-dir")).toEqual([installDir]);
+ expect(recorded("setup-path")[0]?.startsWith(`${installDir}:`)).toBe(true);
+ expect(recorded("github-path")).toEqual([installDir]);
+ });
+
+ test("runs sentry init with the installed binary when SENTRY_INIT=1", () => {
+ env.SENTRY_INIT = "1";
+
+ const result = run();
+
+ expect(result.status, result.stdout + result.stderr).toBe(0);
+ expect(recorded("post-args")).toEqual(["init"]);
+ expect(recorded("post-binary")).toEqual([join(installDir, "sentry.exe")]);
+ });
+
+ test("fails when sentry init fails", () => {
+ env.SENTRY_INIT = "1";
+ env.SENTRY_TEST_POST_EXIT = "7";
+
+ const result = run();
+
+ expect(result.status).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain(
+ "sentry init failed with exit code 7",
+ );
+ expect(recorded("post-args")).toEqual(["init"]);
+ });
+
+ test("leaves PATH alone when SENTRY_NO_MODIFY_PATH=1", () => {
+ env.SENTRY_NO_MODIFY_PATH = "1";
+
+ const result = run();
+
+ expect(result.status, result.stdout + result.stderr).toBe(0);
+ expect(recorded("github-path")).toEqual([]);
+ expect(recorded("setup-path")[0]?.startsWith(`${installDir}:`)).toBe(false);
+ });
+
+ test.each([
+ {
+ name: "non-Windows hosts",
+ setup: () => {
+ delete env.OS;
+ },
+ message: "This installer is for Windows",
+ },
+ {
+ name: "unsupported architectures",
+ setup: () => {
+ env.PROCESSOR_ARCHITECTURE = "ARM64";
+ },
+ message: "only windows-x64 is supported",
+ },
+ {
+ name: "the nightly channel",
+ setup: () => {
+ env.SENTRY_VERSION = "nightly";
+ },
+ message: "sentry cli upgrade nightly",
+ },
+ {
+ name: "a failed setup",
+ setup: () => {
+ env.SENTRY_TEST_SETUP_EXIT = "3";
+ },
+ message: "sentry cli setup failed with exit code 3",
+ },
+ ])("fails clearly for $name", ({ setup, message }) => {
+ setup();
+
+ const result = run();
+
+ expect(result.status).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain(message);
+ });
+});
diff --git a/packages/cli/test/lib/install-scenarios.ts b/packages/cli/test/lib/install-scenarios.ts
new file mode 100644
index 000000000..7efe2698e
--- /dev/null
+++ b/packages/cli/test/lib/install-scenarios.ts
@@ -0,0 +1,261 @@
+/**
+ * Shared behavior table for the bash (`install`) and PowerShell
+ * (`install.ps1`) installers. Both suites run every scenario against their own
+ * HTTP mocks, which follow the same contract, so any behavioral drift between
+ * the two scripts fails CI.
+ *
+ * Mock contract (environment variables read by both mocks):
+ * - SENTRY_TEST_REGISTRY_STATUS: HTTP status for release registry requests
+ * - SENTRY_TEST_REGISTRY_BODY: path to a registry response template; every
+ * `__VERSION__` is replaced by the requested version (or
+ * SENTRY_TEST_LATEST_VERSION for `latest`)
+ * - SENTRY_TEST_RELEASES_PAGE_: path to GitHub releases page n (else `[]`)
+ * - SENTRY_TEST_GZ_MISSING=1: gzip asset downloads fail
+ * - SENTRY_TEST_GZ_FILE / SENTRY_TEST_RAW_FILE: bytes served for downloads
+ * Every requested URL is recorded, in order, for `requests` assertions.
+ */
+
+import { createHash } from "node:crypto";
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+export const LATEST_VERSION = "0.47.0";
+const REGISTRY = "https://release-registry.services.sentry.io/apps/sentry";
+const RELEASES_PAGE =
+ "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=";
+
+export function toolkitDownload(version: string, asset: string): string {
+ return `https://github.com/getsentry/toolkit/releases/download/cli@${version}/${asset}`;
+}
+
+function legacyDownload(version: string, asset: string): string {
+ return `https://github.com/getsentry/cli/releases/download/${version}/${asset}`;
+}
+
+export function sha256(data: string | Buffer): string {
+ return createHash("sha256").update(data).digest("hex");
+}
+
+/** Bytes a runner serves for the gzip and raw downloads of its asset. */
+export interface ServedAsset {
+ name: string;
+ gz: Buffer;
+ raw: string | Buffer;
+}
+
+type RegistryResponse =
+ | {
+ status: 200;
+ /** Which repository the registry's download URLs point at. */
+ repo?: "toolkit" | "legacy";
+ /** Advertise checksums that do not match the served bytes. */
+ tamper?: boolean;
+ /** Omit this platform's files from the release. */
+ missingAsset?: boolean;
+ }
+ | { status: 404 | 503 };
+
+export interface InstallScenario {
+ name: string;
+ /** Value of SENTRY_VERSION, if any. */
+ version?: string;
+ registry: RegistryResponse;
+ /** GitHub releases pages served during a registry outage. */
+ releases?: { tag_name: string; prerelease: boolean }[][];
+ gzMissing?: boolean;
+ ok: boolean;
+ /** Every URL the installer requests, in order. */
+ requests: (asset: string) => string[];
+ /** Substrings expected in combined stdout and stderr. */
+ output?: string[];
+}
+
+/** Build a registry response template in the release registry's shape. */
+export function registryTemplate(
+ served: ServedAsset,
+ options: { repo?: "toolkit" | "legacy"; tamper?: boolean; missing?: boolean },
+): string {
+ const download = options.repo === "legacy" ? legacyDownload : toolkitDownload;
+ const entries: [string, string | Buffer][] = options.missing
+ ? [["sentry-other-platform", "other"]]
+ : [
+ [`${served.name}.gz`, served.gz],
+ [served.name, served.raw],
+ ];
+ const fileUrls: Record = {};
+ const files: Record = {};
+ for (const [name, bytes] of entries) {
+ const url = download("__VERSION__", name);
+ fileUrls[name] = url;
+ files[name] = {
+ checksums: {
+ "sha256-hex": options.tamper ? sha256("tampered") : sha256(bytes),
+ },
+ url,
+ };
+ }
+ return JSON.stringify(
+ {
+ canonical: "app:sentry",
+ file_urls: fileUrls,
+ files,
+ name: "Sentry CLI",
+ version: "__VERSION__",
+ },
+ null,
+ 2,
+ );
+}
+
+/** Write a scenario's fixtures to `dir` and point the mock env at them. */
+export function applyScenario(
+ scenario: InstallScenario,
+ served: ServedAsset,
+ dir: string,
+ env: NodeJS.ProcessEnv,
+): void {
+ const gzFile = join(dir, "served-asset.gz");
+ const rawFile = join(dir, "served-asset");
+ writeFileSync(gzFile, served.gz);
+ writeFileSync(rawFile, served.raw);
+ env.SENTRY_TEST_GZ_FILE = gzFile;
+ env.SENTRY_TEST_RAW_FILE = rawFile;
+ env.SENTRY_TEST_LATEST_VERSION = LATEST_VERSION;
+ env.SENTRY_TEST_REGISTRY_STATUS = String(scenario.registry.status);
+ const body = join(dir, "registry.json");
+ writeFileSync(
+ body,
+ scenario.registry.status === 200
+ ? registryTemplate(served, {
+ repo: scenario.registry.repo,
+ tamper: scenario.registry.tamper,
+ missing: scenario.registry.missingAsset,
+ })
+ : "",
+ );
+ env.SENTRY_TEST_REGISTRY_BODY = body;
+ for (const [index, page] of (scenario.releases ?? []).entries()) {
+ const file = join(dir, `releases-${index + 1}.json`);
+ writeFileSync(file, JSON.stringify(page, null, 2));
+ env[`SENTRY_TEST_RELEASES_PAGE_${index + 1}`] = file;
+ }
+ if (scenario.gzMissing) {
+ env.SENTRY_TEST_GZ_MISSING = "1";
+ }
+ if (scenario.version !== undefined) {
+ env.SENTRY_VERSION = scenario.version;
+ }
+}
+
+export const INSTALL_SCENARIOS: InstallScenario[] = [
+ {
+ name: "installs the latest release from the registry",
+ registry: { status: 200 },
+ ok: true,
+ requests: (asset) => [
+ `${REGISTRY}/latest`,
+ toolkitDownload(LATEST_VERSION, `${asset}.gz`),
+ ],
+ },
+ {
+ name: "installs a pinned legacy release from the registry",
+ version: "v0.42.2",
+ registry: { status: 200, repo: "legacy" },
+ ok: true,
+ requests: (asset) => [
+ `${REGISTRY}/0.42.2`,
+ legacyDownload("0.42.2", `${asset}.gz`),
+ ],
+ },
+ {
+ name: "strips a cli@ prefix from pinned versions",
+ version: "cli@0.46.0",
+ registry: { status: 200 },
+ ok: true,
+ requests: (asset) => [
+ `${REGISTRY}/0.46.0`,
+ toolkitDownload("0.46.0", `${asset}.gz`),
+ ],
+ },
+ {
+ name: "falls back to the raw binary when the gzip asset is missing",
+ registry: { status: 200 },
+ gzMissing: true,
+ ok: true,
+ requests: (asset) => [
+ `${REGISTRY}/latest`,
+ toolkitDownload(LATEST_VERSION, `${asset}.gz`),
+ toolkitDownload(LATEST_VERSION, asset),
+ ],
+ },
+ {
+ name: "rejects a download whose checksum does not match the registry",
+ registry: { status: 200, tamper: true },
+ ok: false,
+ output: ["Checksum mismatch"],
+ requests: (asset) => [
+ `${REGISTRY}/latest`,
+ toolkitDownload(LATEST_VERSION, `${asset}.gz`),
+ ],
+ },
+ {
+ name: "stops when the release has no build for this platform",
+ registry: { status: 200, missingAsset: true },
+ ok: false,
+ output: [`Sentry CLI ${LATEST_VERSION} has no`],
+ requests: () => [`${REGISTRY}/latest`],
+ },
+ {
+ name: "stops on an unknown pinned version",
+ version: "9.9.9",
+ registry: { status: 404 },
+ ok: false,
+ output: ["Unknown Sentry CLI version: 9.9.9"],
+ requests: () => [`${REGISTRY}/9.9.9`],
+ },
+ {
+ name: "rejects an invalid pinned version before any request",
+ version: "1.2",
+ registry: { status: 200 },
+ ok: false,
+ output: ["Invalid release version: 1.2"],
+ requests: () => [],
+ },
+ {
+ name: "falls back to the newest stable Toolkit CLI release when the registry is down",
+ registry: { status: 503 },
+ releases: [
+ [
+ { tag_name: "mcp@1.2.3", prerelease: false },
+ { tag_name: "cli@0.48.0", prerelease: true },
+ ],
+ [{ tag_name: "cli@0.46.0", prerelease: false }],
+ ],
+ ok: true,
+ output: ["Trying GitHub", "not checksum-verified"],
+ requests: (asset) => [
+ `${REGISTRY}/latest`,
+ `${RELEASES_PAGE}1`,
+ `${RELEASES_PAGE}2`,
+ toolkitDownload("0.46.0", `${asset}.gz`),
+ ],
+ },
+ {
+ name: "uses the pinned Toolkit release when the registry is down",
+ version: "0.46.0",
+ registry: { status: 503 },
+ ok: true,
+ output: ["Trying GitHub", "not checksum-verified"],
+ requests: (asset) => [
+ `${REGISTRY}/0.46.0`,
+ toolkitDownload("0.46.0", `${asset}.gz`),
+ ],
+ },
+ {
+ name: "stops when neither the registry nor GitHub has a stable release",
+ registry: { status: 503 },
+ ok: false,
+ output: ["Failed to fetch latest stable version"],
+ requests: () => [`${REGISTRY}/latest`, `${RELEASES_PAGE}1`],
+ },
+];
diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts
index 62f20c550..2825b6379 100644
--- a/packages/cli/test/lib/install-script.test.ts
+++ b/packages/cli/test/lib/install-script.test.ts
@@ -18,6 +18,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import {
+ applyScenario,
+ INSTALL_SCENARIOS,
+ LATEST_VERSION,
+ registryTemplate,
+ type ServedAsset,
+ toolkitDownload,
+} from "./install-scenarios.js";
const installScript = join(import.meta.dirname, "..", "..", "install");
const downloadedExecutable = `#!/usr/bin/env bash
@@ -54,6 +62,13 @@ record_tty 3> "$SENTRY_TEST_DIR/post-tty"
exit "\${SENTRY_TEST_POST_EXIT:-0}"
`;
+const hostAsset = `sentry-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}`;
+const served: ServedAsset = {
+ name: hostAsset,
+ gz: gzipSync(downloadedExecutable),
+ raw: downloadedExecutable,
+};
+
describe("install script", () => {
let testDir: string;
let binDir: string;
@@ -73,186 +88,118 @@ describe("install script", () => {
SENTRY_CLI_NO_TELEMETRY: "1",
SENTRY_TEST_DIR: testDir,
SENTRY_TEST_INSTALL_SCRIPT: installScript,
- SENTRY_TEST_GITHUB_FAIL: "22",
- SENTRY_TEST_GITHUB_RESPONSE: '{"tag_name":"0.42.2"}',
- SENTRY_TEST_TOOLKIT_STATUS: "404",
TMPDIR: testDir,
};
+ applyScenario(
+ { ...INSTALL_SCENARIOS[0], name: "default" },
+ served,
+ testDir,
+ env,
+ );
writeFileSync(
join(binDir, "curl"),
`#!/usr/bin/env bash
set -euo pipefail
url="\${!#}"
+out=""
+args=("$@")
+for ((i = 0; i < \${#args[@]}; i++)); do
+ if [[ "\${args[i]}" == "-o" ]]; then out="\${args[i + 1]}"; fi
+done
+emit() { if [[ -n "$out" ]]; then cat > "$out"; else cat; fi; }
printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/curl-urls"
case "$url" in
- https://release-registry.services.sentry.io/apps/sentry/latest)
- printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls"
- if [[ "\${SENTRY_TEST_REGISTRY_FAIL:-0}" != "0" ]]; then
- exit "$SENTRY_TEST_REGISTRY_FAIL"
+ https://release-registry.services.sentry.io/apps/sentry/*)
+ version="\${url##*/}"
+ if [[ "$version" == "latest" ]]; then version="$SENTRY_TEST_LATEST_VERSION"; fi
+ if [[ "$SENTRY_TEST_REGISTRY_STATUS" == "200" ]]; then
+ sed "s/__VERSION__/$version/g" "$SENTRY_TEST_REGISTRY_BODY" | emit
+ else
+ : | emit
fi
- printf '%s\\n' "\${SENTRY_TEST_REGISTRY_RESPONSE:-}"
- ;;
- *"/repos/getsentry/toolkit/releases/tags/"*)
- printf '%s' "\${SENTRY_TEST_TOOLKIT_STATUS:-200}"
- ;;
- *"/repos/getsentry/cli/releases/tags/"*)
- printf '%s' "\${SENTRY_TEST_LEGACY_STATUS:-200}"
+ printf '%s' "$SENTRY_TEST_REGISTRY_STATUS"
;;
*"/repos/getsentry/toolkit/releases?per_page=100&page="*)
- page="\${url##*=}"
- case "$page" in
- 1) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_1:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_1"; else printf '[]'; fi ;;
- 2) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_2:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_2"; else printf '[]'; fi ;;
- *) printf '[]' ;;
- esac
+ page_file="SENTRY_TEST_RELEASES_PAGE_\${url##*=}"
+ if [[ -n "\${!page_file:-}" ]]; then cat "\${!page_file}"; else printf '[]'; fi
;;
- *"/repos/getsentry/cli/releases/latest")
- printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls"
- if [[ "\${SENTRY_TEST_GITHUB_FAIL:-0}" != "0" ]]; then
- exit "$SENTRY_TEST_GITHUB_FAIL"
- fi
- printf '%s\\n' "$SENTRY_TEST_GITHUB_RESPONSE"
+ https://github.com/*/releases/download/*.gz)
+ if [[ "\${SENTRY_TEST_GZ_MISSING:-}" == "1" ]]; then exit 22; fi
+ emit < "$SENTRY_TEST_GZ_FILE"
+ ;;
+ https://github.com/*/releases/download/*)
+ emit < "$SENTRY_TEST_RAW_FILE"
;;
*)
- printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls"
- cat <<'SCRIPT'
-${downloadedExecutable}SCRIPT
+ exit 91
;;
esac
`,
);
chmodSync(join(binDir, "curl"), 0o755);
- writeFileSync(join(binDir, "gunzip"), "#!/usr/bin/env bash\ncat\n");
- chmodSync(join(binDir, "gunzip"), 0o755);
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
- test.each([
- '{"canonical":"app:sentry","version":"0.45.0"}',
- '{\n "canonical": "app:sentry",\n "version": "0.45.0"\n}',
- JSON.stringify(
- { version: "0.45.0", description: "x".repeat(96 * 1024) },
- null,
- 2,
- ),
- ])(
- "installs the latest stable release when GitHub API access is blocked",
- (metadata) => {
- env.SENTRY_TEST_REGISTRY_RESPONSE = metadata;
- const result = spawnSync("bash", [installScript], {
- env,
- encoding: "utf8",
- timeout: 10_000,
- });
-
- expect(result.status, result.stdout + result.stderr).toBe(0);
- expect(recorded("download-urls")).toEqual([
- "https://release-registry.services.sentry.io/apps/sentry/latest",
- expect.stringMatching(
- /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/,
- ),
- ]);
- expect(existsSync(join(installDir, "sentry"))).toBe(true);
- },
- );
-
- test.each([
- { name: "HTTP error", metadata: "", failure: "22" },
- { name: "timeout", metadata: "", failure: "28" },
- { name: "missing version", metadata: "{}" },
- { name: "non-JSON response", metadata: "Unavailable" },
- { name: "nonstable version", metadata: '{"version":"nightly"}' },
- ])("falls back to GitHub after a registry $name", ({ metadata, failure }) => {
- env.SENTRY_TEST_REGISTRY_RESPONSE = metadata;
- env.SENTRY_TEST_REGISTRY_FAIL = failure;
- env.SENTRY_TEST_GITHUB_FAIL = "0";
- env.SENTRY_TEST_GITHUB_RESPONSE = '{"tag_name":"0.45.0"}';
- const result = spawnSync("bash", [installScript], {
+ test.each(INSTALL_SCENARIOS)("$name", (scenario) => {
+ applyScenario(scenario, served, testDir, env);
+ const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
timeout: 10_000,
});
- expect(result.status, result.stdout + result.stderr).toBe(0);
- expect(result.stderr).toContain("Trying GitHub");
- expect(recorded("download-urls")).toEqual([
- "https://release-registry.services.sentry.io/apps/sentry/latest",
- "https://api.github.com/repos/getsentry/cli/releases/latest",
- expect.stringMatching(
- /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/,
- ),
- ]);
- expect(existsSync(join(installDir, "sentry"))).toBe(true);
+ const output = result.stdout + result.stderr;
+ if (scenario.ok) {
+ expect(result.status, output).toBe(0);
+ expect(existsSync(join(installDir, "sentry"))).toBe(true);
+ } else {
+ expect(result.status, output).not.toBe(0);
+ expect(output).not.toContain("Unexpected failure at line");
+ expect(recorded("setup-args")).toEqual([]);
+ }
+ for (const expected of scenario.output ?? []) {
+ expect(output).toContain(expected);
+ }
+ expect(recorded("curl-urls")).toEqual(scenario.requests(hostAsset));
+ expect(installerTempFiles()).toEqual([]);
});
test.each([
- { name: "a leading v", metadata: '{\n "tag_name": "v0.45.0"\n}' },
{
- name: "large release metadata",
- metadata: JSON.stringify(
- { tag_name: "0.45.0", body: "x".repeat(96 * 1024) },
- null,
- 2,
- ),
+ name: "compact",
+ format: (json: string) => JSON.stringify(JSON.parse(json)),
},
- ])("accepts a GitHub release with $name", ({ metadata }) => {
- env.SENTRY_TEST_REGISTRY_FAIL = "22";
- env.SENTRY_TEST_GITHUB_FAIL = "0";
- env.SENTRY_TEST_GITHUB_RESPONSE = metadata;
- const result = spawnSync("bash", [installScript], {
+ { name: "pretty-printed", format: (json: string) => json },
+ {
+ name: "large",
+ format: (json: string) =>
+ JSON.stringify(
+ { description: "x".repeat(96 * 1024), ...JSON.parse(json) },
+ null,
+ 2,
+ ),
+ },
+ ])("parses $name registry metadata", ({ format }) => {
+ writeFileSync(
+ env.SENTRY_TEST_REGISTRY_BODY as string,
+ format(registryTemplate(served, {})),
+ );
+ const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
timeout: 10_000,
});
expect(result.status, result.stdout + result.stderr).toBe(0);
- expect(recorded("download-urls").at(-1)).toMatch(
- /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/,
+ expect(recorded("curl-urls").at(-1)).toBe(
+ toolkitDownload(LATEST_VERSION, `${hostAsset}.gz`),
);
- expect(existsSync(join(installDir, "sentry"))).toBe(true);
});
- test.each([
- { name: "HTTP error", metadata: "", failure: "22" },
- { name: "timeout", metadata: "", failure: "28" },
- { name: "missing tag", metadata: "{}", failure: "0" },
- {
- name: "non-JSON response",
- metadata: "Unavailable",
- failure: "0",
- },
- { name: "nonstable tag", metadata: '{"tag_name":"nightly"}', failure: "0" },
- ])(
- "stops when the registry and GitHub fail: $name",
- ({ metadata, failure }) => {
- env.SENTRY_TEST_REGISTRY_FAIL = "22";
- env.SENTRY_TEST_GITHUB_RESPONSE = metadata;
- env.SENTRY_TEST_GITHUB_FAIL = failure;
- const result = spawnSync("bash", [installScript], {
- env,
- encoding: "utf8",
- timeout: 10_000,
- });
-
- expect(result.status).toBe(1);
- expect(result.stderr).toContain(
- "Failed to fetch latest stable version from Sentry's release registry and GitHub",
- );
- expect(result.stderr).toContain("--version ");
- expect(result.stderr).not.toContain("Unexpected failure at line");
- expect(recorded("download-urls")).toEqual([
- "https://release-registry.services.sentry.io/apps/sentry/latest",
- "https://api.github.com/repos/getsentry/cli/releases/latest",
- ]);
- expect(recorded("setup-args")).toEqual([]);
- expect(existsSync(join(installDir, "sentry"))).toBe(false);
- },
- );
-
function recorded(name: string): string[] {
const path = join(testDir, name);
return existsSync(path)
@@ -429,11 +376,9 @@ process.exitCode = result.status ?? 1;
expect(recorded("post-args")).toEqual([]);
expect(existsSync(join(installDir, "sentry"))).toBe(true);
expect(installerTempFiles()).toEqual([]);
- expect(recorded("download-urls")).toEqual([
- expect.stringMatching(
- /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.31\.0\/sentry-.+\.gz$/,
- ),
- ]);
+ expect(recorded("curl-urls").at(-1)).toBe(
+ toolkitDownload("0.31.0", `${hostAsset}.gz`),
+ );
});
test.each([
@@ -505,38 +450,6 @@ process.exitCode = result.status ?? 1;
},
);
- test("uses the legacy release only after a Toolkit tag returns HTTP 404", () => {
- env.SENTRY_TEST_TOOLKIT_STATUS = "404";
- const result = spawnSync(
- "bash",
- [installScript, "--version", "0.42.2", "--no-modify-path"],
- { env, encoding: "utf8", timeout: 10_000 },
- );
-
- expect(result.status, result.stdout + result.stderr).toBe(0);
- expect(recorded("curl-urls")).toEqual([
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2",
- "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2",
- `https://github.com/getsentry/cli/releases/download/0.42.2/sentry-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}.gz`,
- ]);
- expect(existsSync(join(installDir, "sentry"))).toBe(true);
- });
-
- test("never falls back to legacy for a Toolkit server failure", () => {
- env.SENTRY_TEST_TOOLKIT_STATUS = "500";
- const result = spawnSync("bash", [installScript, "--version", "0.42.2"], {
- env,
- encoding: "utf8",
- timeout: 10_000,
- });
-
- expect(result.status).not.toBe(0);
- expect(result.stderr).toContain("Toolkit release check failed (HTTP 500)");
- expect(recorded("curl-urls")).toEqual([
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2",
- ]);
- });
-
test("checks further release pages before selecting a stable Toolkit CLI", () => {
const firstPage = join(testDir, "releases-1.json");
const secondPage = join(testDir, "releases-2.json");
@@ -550,7 +463,7 @@ process.exitCode = result.status ?? 1;
);
env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage;
env.SENTRY_TEST_RELEASES_PAGE_2 = secondPage;
- env.SENTRY_TEST_TOOLKIT_STATUS = "200";
+ env.SENTRY_TEST_REGISTRY_STATUS = "503";
const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
@@ -564,10 +477,7 @@ process.exitCode = result.status ?? 1;
"https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=2",
]);
expect(recorded("curl-urls")[3]).toBe(
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0",
- );
- expect(recorded("curl-urls")[4]).toContain(
- "/getsentry/toolkit/releases/download/cli@0.46.0/",
+ toolkitDownload("0.46.0", `${hostAsset}.gz`),
);
});
@@ -578,7 +488,7 @@ process.exitCode = result.status ?? 1;
'[\n {\n "tag_name": "cli@0.47.0-dev.1",\n "prerelease": false\n },\n {\n "tag_name": "cli@0.46.0",\n "prerelease": false\n }\n]\n',
);
env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage;
- env.SENTRY_TEST_TOOLKIT_STATUS = "200";
+ env.SENTRY_TEST_REGISTRY_STATUS = "503";
const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
@@ -587,10 +497,7 @@ process.exitCode = result.status ?? 1;
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(recorded("curl-urls")[2]).toBe(
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0",
- );
- expect(recorded("curl-urls")[3]).toContain(
- "/getsentry/toolkit/releases/download/cli@0.46.0/",
+ toolkitDownload("0.46.0", `${hostAsset}.gz`),
);
expect(recorded("setup-args")).toContain("stable");
});
@@ -617,7 +524,7 @@ process.exitCode = result.status ?? 1;
]),
);
env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage;
- env.SENTRY_TEST_TOOLKIT_STATUS = "200";
+ env.SENTRY_TEST_REGISTRY_STATUS = "503";
const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
@@ -626,10 +533,7 @@ process.exitCode = result.status ?? 1;
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(recorded("curl-urls")[2]).toBe(
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0",
- );
- expect(recorded("curl-urls")[3]).toContain(
- "/getsentry/toolkit/releases/download/cli@0.46.0/",
+ toolkitDownload("0.46.0", `${hostAsset}.gz`),
);
});
@@ -650,7 +554,7 @@ process.exitCode = result.status ?? 1;
]`,
);
env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage;
- env.SENTRY_TEST_TOOLKIT_STATUS = "200";
+ env.SENTRY_TEST_REGISTRY_STATUS = "503";
const result = spawnSync("bash", [installScript, "--no-modify-path"], {
env,
encoding: "utf8",
@@ -659,7 +563,7 @@ process.exitCode = result.status ?? 1;
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(recorded("curl-urls")).toContain(
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.46.0",
+ toolkitDownload("0.46.0", `${hostAsset}.gz`),
);
expect(existsSync(join(installDir, "sentry"))).toBe(true);
});
@@ -683,27 +587,6 @@ process.exitCode = result.status ?? 1;
expect(recorded("curl-urls")).toEqual([]);
});
- test("resolves the latest legacy version until Toolkit has a CLI release", () => {
- env.SENTRY_TEST_TOOLKIT_STATUS = "404";
- env.SENTRY_TEST_GITHUB_FAIL = "0";
- const result = spawnSync("bash", [installScript, "--no-modify-path"], {
- env,
- encoding: "utf8",
- timeout: 10_000,
- });
-
- expect(result.status, result.stdout + result.stderr).toBe(0);
- expect(recorded("curl-urls").slice(0, 4)).toEqual([
- "https://release-registry.services.sentry.io/apps/sentry/latest",
- "https://api.github.com/repos/getsentry/toolkit/releases?per_page=100&page=1",
- "https://api.github.com/repos/getsentry/cli/releases/latest",
- "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.42.2",
- ]);
- expect(recorded("curl-urls")[4]).toBe(
- "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2",
- );
- });
-
test("connects setup to the controlling terminal and cleans up after setup", () => {
env.SENTRY_TEST_KEEP_TEMP_BINARY = "1";
const result = runInTerminal();