# Gimmer CLI installer for Windows PowerShell 5.1 and PowerShell 7. # Download and review this script, or run: irm https://gimmer.com/install/cli.ps1 | iex [CmdletBinding()] param( [string]$InstallDir, [switch]$NoModifyPath ) # Resolve the native OS architecture instead of mistaking x64 emulation for support. function Get-GimmerArchitecture { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'This installer requires Windows. Use the macOS or Ubuntu installer instead.' } try { return [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { throw 'Windows architecture could not be verified. Use an up-to-date Windows PowerShell or PowerShell 7.' } } # Reject malformed metadata before constructing a URL or touching an installed binary. function ConvertFrom-GimmerManifest { param([string]$Content) if ($Content.Length -gt 4096 -or $Content -cnotmatch '\Agimmer-cli-v1\tv[0-9]+\.[0-9]+\.[0-9]+\tgimmer-cli-win\.exe\t[a-f0-9]{64}\t[1-9][0-9]*\r?\n?\z') { throw 'The release manifest is invalid. No files were installed.' } $fields = $Content.TrimEnd([char[]]"`r`n").Split([char]9) $size = 0L if (-not [long]::TryParse($fields[4], [ref]$size) -or $size -gt 1073741824) { throw 'The release size is invalid or exceeds the 1 GiB safety limit.' } return [pscustomobject]@{ Version = $fields[1] Asset = $fields[2] Sha256 = $fields[3] Size = $size Url = 'https://github.com/GimmerBot/Gimmer/releases/download/' + $fields[1] + '/' + $fields[2] } } # Every redirect must remain HTTPS and inside the expected public delivery hosts. function Assert-GimmerDownloadUri { param([uri]$Uri, [string[]]$AllowedHosts) if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne 'https' -or $Uri.Port -ne 443 -or $Uri.UserInfo -or $Uri.Fragment -or $AllowedHosts -notcontains $Uri.DnsSafeHost) { throw 'The download address is not an approved HTTPS endpoint.' } } # Stream responses within explicit size/time limits; never load the executable into memory. function Receive-GimmerDownload { param([uri]$Uri, [string[]]$AllowedHosts, [System.IO.Stream]$Destination, [long]$MaximumBytes, [int]$TimeoutSeconds) $clock = [Diagnostics.Stopwatch]::StartNew() $previousTls = [Net.ServicePointManager]::SecurityProtocol try { # Older Windows PowerShell defaults may otherwise negotiate obsolete TLS. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 for ($redirect = 0; $redirect -le 5; $redirect++) { Assert-GimmerDownloadUri -Uri $Uri -AllowedHosts $AllowedHosts $remaining = ($TimeoutSeconds * 1000) - [int]$clock.ElapsedMilliseconds if ($remaining -le 0) { throw 'The download timed out. Please try again.' } $request = [Net.HttpWebRequest]::Create($Uri) $request.Method = 'GET' $request.AllowAutoRedirect = $false $request.UseDefaultCredentials = $false $request.Timeout = $remaining $request.ReadWriteTimeout = [Math]::Min(30000, $remaining) $request.UserAgent = 'Gimmer-CLI-Installer/1' $response = $null $source = $null try { $response = $request.GetResponse() $status = [int]$response.StatusCode if ($status -in @(301, 302, 303, 307, 308)) { if ($redirect -eq 5 -or -not $response.Headers['Location']) { throw 'The download redirected too many times.' } $Uri = [uri]::new($Uri, $response.Headers['Location']) continue } if ($status -ne 200) { throw 'The download did not return HTTP 200.' } if ($response.ContentLength -gt $MaximumBytes) { throw 'The download exceeds its expected size.' } $source = $response.GetResponseStream() $buffer = New-Object byte[] 65536 $received = 0L while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { if ($clock.Elapsed.TotalSeconds -ge $TimeoutSeconds) { throw 'The download timed out. Please try again.' } $received += $count if ($received -gt $MaximumBytes) { throw 'The download exceeds its expected size.' } $Destination.Write($buffer, 0, $count) } return } finally { if ($null -ne $source) { $source.Dispose() } if ($null -ne $response) { $response.Dispose() } $request.Abort() } } } catch { # Avoid reflecting signed redirect URLs or proxy details into the terminal. throw 'Could not download the official Gimmer release over HTTPS. Check your connection and try again.' } finally { [Net.ServicePointManager]::SecurityProtocol = $previousTls } } # Fetch only the small, public release contract from the Gimmer website. function Get-GimmerManifest { $buffer = New-Object IO.MemoryStream try { Receive-GimmerDownload -Uri 'https://gimmer.com/download/cli/windows-amd64' -AllowedHosts @('gimmer.com', 'www.gimmer.com') -Destination $buffer -MaximumBytes 4096 -TimeoutSeconds 30 return ConvertFrom-GimmerManifest -Content ([Text.Encoding]::UTF8.GetString($buffer.ToArray())) } finally { $buffer.Dispose() } } # Reject junctions/symlinks in every existing ancestor, not only the final directory. function Assert-GimmerSafePath { param([string]$Path) $cursor = [IO.Path]::GetFullPath($Path) if ($cursor -notmatch '^[A-Za-z]:\\' -or $cursor -eq [IO.Path]::GetPathRoot($cursor) -or $cursor.Substring(2).Contains(':') -or $cursor.Contains(';')) { throw 'Choose a local installation directory below a drive root.' } while ($cursor) { if (Test-Path -LiteralPath $cursor) { $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Installation paths cannot contain symlinks or directory junctions.' } } $parent = [IO.Directory]::GetParent($cursor) if ($null -eq $parent) { break } $cursor = $parent.FullName } } # This pin matches the desktop updater and the official Windows signing pipeline. function Assert-GimmerSignature { param([string]$Path) $signature = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction Stop if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Thumbprint -ne '6730D0F481BB3D0FEC8A5F74DEE2980B49408920') { throw 'The Windows publisher signature could not be verified. The existing CLI was not changed.' } } # Normalize PATH entries for comparison only; preserve existing text and other entries. function Get-GimmerPathKey { param([string]$Entry) return $Entry.Trim().Trim('"').TrimEnd('\').ToLowerInvariant() } # A small seam permits registry-free tests without changing the production behavior. function Get-GimmerUserPath { return [Environment]::GetEnvironmentVariable('Path', 'User') } function Set-GimmerUserPath { param([string]$Value) [Environment]::SetEnvironmentVariable('Path', $Value, 'User') } # Persist the user PATH without setx truncation, and enable the command in this terminal. function Add-GimmerPath { param([string]$Directory) $key = Get-GimmerPathKey -Entry $Directory $userPath = Get-GimmerUserPath $userEntries = @($userPath -split ';' | ForEach-Object { Get-GimmerPathKey -Entry $_ }) if ($userEntries -notcontains $key) { $updated = if ([string]::IsNullOrWhiteSpace($userPath)) { $Directory } else { $userPath.TrimEnd(';') + ';' + $Directory } Set-GimmerUserPath -Value $updated } $sessionEntries = @($env:Path -split ';' | ForEach-Object { Get-GimmerPathKey -Entry $_ }) if ($sessionEntries -notcontains $key) { $env:Path = $Directory + ';' + $env:Path } } # Install only a verified, headless executable. No services or accounts are started. function Install-GimmerCli { [CmdletBinding()] param([string]$InstallDir, [switch]$NoModifyPath) $ErrorActionPreference = 'Stop' if ((Get-GimmerArchitecture) -ne 'X64') { throw 'Gimmer CLI currently supports Windows x64 only. Windows ARM and 32-bit Windows are not supported by this installer.' } if ([string]::IsNullOrWhiteSpace($InstallDir)) { if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { throw 'LOCALAPPDATA is unavailable. Specify -InstallDir with a local directory.' } $InstallDir = Join-Path $env:LOCALAPPDATA 'Gimmer\bin' } $directory = [IO.Path]::GetFullPath($InstallDir) Assert-GimmerSafePath -Path $directory $directory = $directory.TrimEnd('\') $target = Join-Path $directory 'gimmer-cli.exe' Assert-GimmerSafePath -Path $target if ((Test-Path -LiteralPath $target) -and (Get-Item -LiteralPath $target -Force).PSIsContainer) { throw 'The installation target is a directory, not an executable.' } $manifest = Get-GimmerManifest [IO.Directory]::CreateDirectory($directory) | Out-Null Assert-GimmerSafePath -Path $directory $temporary = Join-Path $directory ('.gimmer-cli-' + [guid]::NewGuid().ToString('N') + '.exe') $ownsTemporary = $false try { Write-Host ('Downloading Gimmer CLI ' + $manifest.Version + ' (' + [Math]::Ceiling($manifest.Size / 1MB) + ' MiB)...') $output = [IO.File]::Open($temporary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $ownsTemporary = $true try { Receive-GimmerDownload -Uri $manifest.Url -AllowedHosts @('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com') -Destination $output -MaximumBytes $manifest.Size -TimeoutSeconds 600 } finally { $output.Dispose() } Write-Host 'Verifying the release checksum and Windows publisher signature...' if ((Get-Item -LiteralPath $temporary).Length -ne $manifest.Size) { throw 'The downloaded file size does not match the release. The existing CLI was not changed.' } if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant() -cne $manifest.Sha256) { throw 'The SHA-256 verification failed. The existing CLI was not changed.' } Assert-GimmerSignature -Path $temporary # Both files live on the same volume; replacement is atomic and fails if locked. Assert-GimmerSafePath -Path $target Assert-GimmerSafePath -Path $temporary try { if (Test-Path -LiteralPath $target) { [IO.File]::Replace($temporary, $target, [NullString]::Value) } else { [IO.File]::Move($temporary, $target) } $ownsTemporary = $false } catch { throw 'Could not replace gimmer-cli.exe. Stop any running Gimmer CLI/server process, then rerun this installer. The existing file was preserved.' } if (-not $NoModifyPath) { try { Add-GimmerPath -Directory $directory } catch { Write-Warning ('Gimmer CLI was installed, but PATH could not be updated. Run it with: & "' + $target + '" --help') } } Write-Host ('Installed Gimmer CLI ' + $manifest.Version + ' at ' + $target) Write-Host 'No desktop renderer, service, wallet or bot was started.' if (-not $NoModifyPath) { Write-Host 'Next: gimmer-cli --help' } else { Write-Host ('Next: & "' + $target + '" --help') } return $target } finally { # Remove only our uniquely named file; never recursively delete user directories. if ($ownsTemporary -and (Test-Path -LiteralPath $temporary)) { Assert-GimmerSafePath -Path $temporary [IO.File]::Delete($temporary) } } } # Dot-sourcing loads the functions for review/tests; direct execution and IEX install. if ($MyInvocation.InvocationName -ne '.') { Install-GimmerCli -InstallDir $InstallDir -NoModifyPath:$NoModifyPath }