diff --git a/apps/pythinker-code/README.md b/apps/pythinker-code/README.md index 0b74772c..0fbcc277 100644 --- a/apps/pythinker-code/README.md +++ b/apps/pythinker-code/README.md @@ -5,7 +5,7 @@ [![npm](https://img.shields.io/npm/v/@pythoughts/pythinker-code)](https://www.npmjs.com/package/@pythoughts/pythinker-code) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://pythoughts-labs.github.io/pythinker-code/)

- Pythinker Code terminal demo + Pythinker Code terminal demo

## What is Pythinker Code CLI diff --git a/apps/site/index.html b/apps/site/index.html index 9a2bbc2c..68dda94e 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -6,11 +6,37 @@ + + + + + + + + + + + + + + + + + + + + + + Pythinker + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/site/public/code/oauth-success.html b/apps/site/public/code/oauth-success.html new file mode 100644 index 00000000..7d5dde65 --- /dev/null +++ b/apps/site/public/code/oauth-success.html @@ -0,0 +1,79 @@ + + + + + + Signed in to Pythinker + + + +
+ +

Pythinker Code

+
+ +
+

You're logged in to Pythinker

+

You can close this tab and return to Pythinker.

+
+ + diff --git a/apps/site/public/favicon.ico b/apps/site/public/favicon.ico new file mode 100644 index 00000000..5887d0bb Binary files /dev/null and b/apps/site/public/favicon.ico differ diff --git a/apps/site/public/icon-192.png b/apps/site/public/icon-192.png new file mode 100644 index 00000000..bc17dcdd Binary files /dev/null and b/apps/site/public/icon-192.png differ diff --git a/apps/site/public/icon-512.png b/apps/site/public/icon-512.png new file mode 100644 index 00000000..596d244a Binary files /dev/null and b/apps/site/public/icon-512.png differ diff --git a/apps/site/public/icon.svg b/apps/site/public/icon.svg new file mode 100644 index 00000000..4454e2df --- /dev/null +++ b/apps/site/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/site/public/install.ps1 b/apps/site/public/install.ps1 new file mode 100644 index 00000000..b7aee99a --- /dev/null +++ b/apps/site/public/install.ps1 @@ -0,0 +1,924 @@ +# Pythinker Code — native Windows installer. +# +# Downloads the native single-file binary (pythinker-code-win32-.zip) +# from the GitHub Release matching the CDN's latest version, verifies its +# SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker +# (added to the user PATH). +# +# Usage: +# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex +# +# To pin a version when running the hosted script, set: +# $env:PYTHINKER_VERSION = "0.6.0"; irm https://code.pythinker.com/pythinker-code/install.ps1 | iex +# +# Or run the script directly: +# .\install.ps1 -Version 0.6.0 +# +# Terminal controls: +# $env:PYTHINKER_NO_ANIMATION = "1" # Disable motion, keep concise output. +# $env:NO_COLOR = "1" # Disable ANSI colors. + +[CmdletBinding()] +param( + [string]$Version = $env:PYTHINKER_VERSION, + [switch]$Help +) + +# Invoke the implementation in a child scope. This matters for the hosted +# `irm ... | iex` form: functions, preferences, and temporary variables must +# not leak into the caller's interactive PowerShell session. +& { + param( + [string]$RequestedVersion, + [bool]$ShowHelp + ) + + $ErrorActionPreference = "Stop" + Set-StrictMode -Version 2.0 + + $Repo = "Pythoughts-labs/pythinker-code" + $CdnLatestUrl = "https://code.pythinker.com/pythinker-code/latest" + $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" + $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" + + $previousOutputEncoding = $null + $previousSecurityProtocol = $null + $httpClient = $null + $installMutex = $null + $mutexHeld = $false + $tempDir = $null + $stagingBinary = $null + $backupBinary = $null + $targetPath = $null + + try { $previousOutputEncoding = [Console]::OutputEncoding } catch {} + try { $previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol } catch {} + + try { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + } catch {} + + # Add TLS 1.2 without discarding newer protocols selected by the host. + try { + $currentProtocols = [Net.ServicePointManager]::SecurityProtocol + $tls12 = [Net.SecurityProtocolType]::Tls12 + if (($currentProtocols -band $tls12) -eq 0) { + [Net.ServicePointManager]::SecurityProtocol = $currentProtocols -bor $tls12 + } + } catch {} + + function Test-EnvironmentVariablePresent([string]$Name) { + return $null -ne [Environment]::GetEnvironmentVariable($Name, 'Process') + } + + function Test-InteractiveTerminal { + try { + if ([Console]::IsOutputRedirected) { return $false } + $null = $Host.UI.RawUI.WindowSize + return $true + } catch { + return $false + } + } + + function Test-AnsiSupport { + if (-not (Test-InteractiveTerminal)) { return $false } + + $term = [Environment]::GetEnvironmentVariable('TERM', 'Process') + if ($term -and $term -ieq 'dumb') { return $false } + + try { + if ([bool]$Host.UI.SupportsVirtualTerminal) { return $true } + } catch {} + + if (Test-EnvironmentVariablePresent 'WT_SESSION') { return $true } + if (Test-EnvironmentVariablePresent 'ANSICON') { return $true } + if ($env:ConEmuANSI -eq 'ON') { return $true } + if ($term -and $term -match '(?i)(xterm|ansi|screen|cygwin|msys|vt100)') { return $true } + + return $false + } + + $interactiveTerminal = Test-InteractiveTerminal + $ansiSupported = Test-AnsiSupport + $useColor = $ansiSupported -and -not (Test-EnvironmentVariablePresent 'NO_COLOR') + $useAnimation = $ansiSupported ` + -and $interactiveTerminal ` + -and -not (Test-EnvironmentVariablePresent 'CI') ` + -and -not (Test-EnvironmentVariablePresent 'PYTHINKER_NO_ANIMATION') + + $ESC = [char]27 + $NAVY = $FACE = $ACCENT = $TIP = $EYE = $BAR = $DIM = $BOLD = $RESET = $SHINE = $SOFT = $ERROR_COLOR = "" + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $ACCENT = "$ESC[38;5;147m" + $TIP = "$ESC[38;5;216m" + $EYE = "$ESC[38;5;189m" + $BAR = "$ESC[38;5;250m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + $SHINE = "$ESC[38;5;231m" + $SOFT = "$ESC[38;5;111m" + $ERROR_COLOR = "$ESC[38;5;203m" + } + + $HIDE_CURSOR = "" + $SHOW_CURSOR = "" + $CLEAR_LINE = "" + if ($useAnimation) { + $HIDE_CURSOR = "$ESC[?25l" + $SHOW_CURSOR = "$ESC[?25h" + $CLEAR_LINE = "$ESC[2K" + } + + function Stop-Installer([string]$Message) { + throw "Pythinker Code install failed: $Message" + } + + function Show-Usage { + @" +Pythinker Code — native Windows installer. + +Downloads the native single-file binary (pythinker-code-win32-.zip) +from the GitHub Release matching the CDN's latest version, verifies its +SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker +(added to the user PATH). + +Usage: + irm $InstallPs1Url | iex + + # Pin a version: + `$env:PYTHINKER_VERSION = "0.6.0"; irm $InstallPs1Url | iex + + # Or run directly: + .\install.ps1 -Version 0.6.0 + +Terminal controls: + `$env:PYTHINKER_NO_ANIMATION = "1" # Disable motion. + `$env:NO_COLOR = "1" # Disable ANSI colors. + +Unix / macOS / Linux users: + curl -fsSL $InstallShUrl | bash +"@ + } + + function Get-TerminalWidth { + $width = 80 + try { $width = [int]$Host.UI.RawUI.WindowSize.Width } catch {} + return [Math]::Max(48, [Math]::Min(120, $width)) + } + + function Get-AnimationDelay([string]$EnvironmentName, [int]$DefaultMilliseconds) { + $raw = [Environment]::GetEnvironmentVariable($EnvironmentName, 'Process') + if (-not $raw) { return $DefaultMilliseconds } + + try { + $milliseconds = [int]([double]$raw * 1000) + return [Math]::Max(0, [Math]::Min(2000, $milliseconds)) + } catch { + return $DefaultMilliseconds + } + } + + function Write-Logo { + $frameDelay = Get-AnimationDelay 'PYTHINKER_LOGO_FRAME_DELAY' 45 + $taglineDelay = Get-AnimationDelay 'PYTHINKER_LOGO_STAGGER_DELAY' 14 + + $logo = @( + " ${TIP}●${RESET}", + " ${NAVY}│${RESET}", + " ${NAVY}▛${RESET}${FACE}▀▀▀▀▀▀▀${RESET}${NAVY}▜${RESET}", + " ${TIP}◖${RESET}${NAVY}█${RESET} ${EYE}◉${RESET} ${EYE}◉${RESET} ${NAVY}█${RESET}${TIP}◗${RESET}", + " ${NAVY}▙▄▄▄${RESET}${FACE}≡${RESET}${NAVY}▄▄▄▟${RESET}" + ) + + Write-Host "" + foreach ($line in $logo) { + Write-Host $line + if ($useAnimation -and $frameDelay -gt 0) { + Start-Sleep -Milliseconds $frameDelay + } + } + + $tagline = "Pythinker Code Think first. Then code." + Write-Host "" + Write-Host -NoNewline " " + if ($useAnimation) { + foreach ($character in $tagline.ToCharArray()) { + Write-Host -NoNewline $character + if ($taglineDelay -gt 0) { Start-Sleep -Milliseconds $taglineDelay } + } + Write-Host "" + } else { + Write-Host $tagline + } + Write-Host "" + } + + function Write-MetadataRow([string]$Label, [string]$Value) { + Write-Host (" {0}{1,-10}{2} {3}" -f $DIM, $Label, $RESET, $Value) + } + + function Write-PhaseOk([string]$Label, [string]$Detail) { + $suffix = if ($Detail) { " ${DIM}$Detail${RESET}" } else { "" } + Write-Host (" ${ACCENT}✓${RESET} {0,-10}{1}" -f $Label, $suffix) + } + + function Write-PhaseInfo([string]$Label, [string]$Detail) { + Write-Host (" ${SOFT}•${RESET} {0,-10} ${DIM}{1}${RESET}" -f $Label, $Detail) + } + + function Write-RetryLine([string]$Label, [int]$Attempt, [int]$DelaySeconds, [string]$Reason) { + if ($useAnimation) { + Write-Host -NoNewline ("`r${CLEAR_LINE}") + } + Write-Host (" ${TIP}↻${RESET} {0,-10} retry {1}/3 in {2}s ${DIM}{3}${RESET}" -f $Label, $Attempt, $DelaySeconds, $Reason) + } + + function Format-ByteSize([long]$Bytes) { + if ($Bytes -lt 1024) { return "$Bytes B" } + if ($Bytes -lt 1MB) { return ("{0:N1} KB" -f ($Bytes / 1KB)) } + if ($Bytes -lt 1GB) { return ("{0:N1} MB" -f ($Bytes / 1MB)) } + return ("{0:N2} GB" -f ($Bytes / 1GB)) + } + + function Write-DownloadStarted([string]$Label) { + Write-Host (" ${SOFT}↓${RESET} {0,-10} ${DIM}starting…${RESET}" -f $Label) + } + + function Write-DownloadProgress( + [long]$ReceivedBytes, + $TotalBytes, + [double]$ElapsedSeconds, + [int]$FrameIndex + ) { + if (-not $useAnimation) { return } + + $spinnerFrames = @('●', '◐', '◓', '◑', '◒') + $spinner = $spinnerFrames[$FrameIndex % $spinnerFrames.Length] + $terminalWidth = Get-TerminalWidth + $barWidth = [Math]::Max(12, [Math]::Min(40, $terminalWidth - 44)) + $rate = if ($ElapsedSeconds -gt 0.05) { [long]($ReceivedBytes / $ElapsedSeconds) } else { 0 } + $rateText = if ($rate -gt 0) { "$(Format-ByteSize $rate)/s" } else { "—/s" } + + if ($null -ne $TotalBytes -and [long]$TotalBytes -gt 0) { + $total = [long]$TotalBytes + $percent = [Math]::Min(100, [Math]::Floor(($ReceivedBytes * 100.0) / $total)) + $filled = [int][Math]::Floor(($percent * $barWidth) / 100) + $empty = $barWidth - $filled + $barText = ("━" * $filled) + ("─" * $empty) + $metrics = "{0,3}% {1}/{2} {3}" -f $percent, (Format-ByteSize $ReceivedBytes), (Format-ByteSize $total), $rateText + $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $metrics" + } else { + $position = $FrameIndex % $barWidth + $left = "─" * $position + $rightCount = [Math]::Max(0, $barWidth - $position - 1) + $right = "─" * $rightCount + $barText = "${left}${SHINE}◆${RESET}${BAR}${right}" + $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $(Format-ByteSize $ReceivedBytes) $rateText" + } + + Write-Host -NoNewline ("`r${CLEAR_LINE}${line}") + } + + function Write-DownloadComplete([long]$Bytes, [double]$ElapsedSeconds) { + if ($useAnimation) { + Write-Host -NoNewline ("`r${CLEAR_LINE}") + } + + $duration = [Math]::Max(0.01, $ElapsedSeconds) + $averageRate = [long]($Bytes / $duration) + Write-Host (" ${ACCENT}✓${RESET} {0,-10} {1} ${DIM}in {2:N1}s · {3}/s${RESET}" -f 'Download', (Format-ByteSize $Bytes), $duration, (Format-ByteSize $averageRate)) + } + + function New-InstallerHttpClient { + try { + Add-Type -AssemblyName System.Net.Http -ErrorAction Stop + } catch { + Stop-Installer "System.Net.Http is unavailable: $($_.Exception.Message)" + } + + $handler = New-Object System.Net.Http.HttpClientHandler + $handler.AllowAutoRedirect = $true + $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler + $client.Timeout = [TimeSpan]::FromMinutes(15) + [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') + [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') + return $client + } + + function Get-HttpTextOnce($Client, [string]$Uri, [string]$Description) { + $response = $null + try { + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + throw "$Description failed with HTTP $status $($response.ReasonPhrase)" + } + return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } finally { + if ($null -ne $response) { $response.Dispose() } + } + } + + function Get-HttpText($Client, [string]$Uri, [string]$Description) { + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + return Get-HttpTextOnce $Client $Uri $Description + } catch { + $lastError = $_.Exception.Message + if ($attempt -lt 3) { + $delay = [Math]::Pow(2, $attempt - 1) + Write-RetryLine $Description ($attempt + 1) ([int]$delay) $lastError + Start-Sleep -Seconds $delay + } + } + } + throw "$Description failed after 3 attempts: $lastError" + } + + function Get-HttpJson($Client, [string]$Uri, [switch]$AllowNotFound) { + $response = $null + try { + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() + $status = [int]$response.StatusCode + if ($AllowNotFound -and $status -eq 404) { return $null } + if (-not $response.IsSuccessStatusCode) { + throw "GitHub API failed with HTTP $status $($response.ReasonPhrase)" + } + $json = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + return $json | ConvertFrom-Json + } finally { + if ($null -ne $response) { $response.Dispose() } + } + } + + function Download-File($Client, [string]$Uri, [string]$Destination, [string]$Label) { + $lastError = $null + + for ($attempt = 1; $attempt -le 3; $attempt++) { + $partialPath = "$Destination.part" + $response = $null + $inputStream = $null + $outputStream = $null + $stopwatch = $null + $received = [long]0 + $frameIndex = 0 + + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue + + if (-not $useAnimation) { + Write-DownloadStarted $Label + } + + try { + if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } + + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + throw "$Label failed with HTTP $status $($response.ReasonPhrase)" + } + + $totalBytes = $response.Content.Headers.ContentLength + $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $outputStream = [System.IO.File]::Open( + $partialPath, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None + ) + + $buffer = New-Object byte[] 131072 + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $lastRenderMilliseconds = [long]-1000 + + while ($true) { + $read = $inputStream.Read($buffer, 0, $buffer.Length) + if ($read -le 0) { break } + + $outputStream.Write($buffer, 0, $read) + $received += $read + + if ($useAnimation -and ($stopwatch.ElapsedMilliseconds - $lastRenderMilliseconds) -ge 80) { + Write-DownloadProgress $received $totalBytes $stopwatch.Elapsed.TotalSeconds $frameIndex + $lastRenderMilliseconds = $stopwatch.ElapsedMilliseconds + $frameIndex++ + } + } + + $outputStream.Flush($true) + $outputStream.Dispose() + $outputStream = $null + $inputStream.Dispose() + $inputStream = $null + $response.Dispose() + $response = $null + $stopwatch.Stop() + + if ($null -ne $totalBytes -and [long]$totalBytes -gt 0 -and $received -ne [long]$totalBytes) { + throw "$Label was truncated: expected $totalBytes bytes, received $received" + } + if ($received -le 0) { throw "$Label returned an empty file" } + + [System.IO.File]::Move($partialPath, $Destination) + Write-DownloadComplete $received $stopwatch.Elapsed.TotalSeconds + return + } catch { + $lastError = $_.Exception.Message + } finally { + if ($null -ne $outputStream) { $outputStream.Dispose() } + if ($null -ne $inputStream) { $inputStream.Dispose() } + if ($null -ne $response) { $response.Dispose() } + if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } + Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue + if ($useAnimation) { + Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") + } + } + + if ($attempt -lt 3) { + $delay = [Math]::Pow(2, $attempt - 1) + Write-RetryLine $Label ($attempt + 1) ([int]$delay) $lastError + Start-Sleep -Seconds $delay + } + } + + Stop-Installer "$Label failed after 3 attempts: $lastError" + } + + function Test-Version([string]$Candidate) { + return $Candidate -match '^\d+\.\d+\.\d+$' + } + + function Get-ReleaseTag([string]$ResolvedVersion) { + return "@pythoughts/pythinker-code@$ResolvedVersion" + } + + function Get-EncodedReleaseTag([string]$ResolvedVersion) { + return [uri]::EscapeDataString((Get-ReleaseTag $ResolvedVersion)) + } + + function Test-ReleaseHasAsset($Release, [string]$AssetName) { + if ($null -eq $Release) { return $false } + if ($Release.draft -or $Release.prerelease) { return $false } + $names = @($Release.assets | ForEach-Object { [string]$_.name }) + return (($names -contains $AssetName) -and ($names -contains "$AssetName.sha256")) + } + + function Get-LatestVersion($Client) { + try { + $raw = Get-HttpText $Client $CdnLatestUrl 'CDN latest version' + $candidate = ([string]$raw).Trim().Trim('"') + if (Test-Version $candidate) { return $candidate } + } catch { + Write-PhaseInfo 'Version' 'CDN unavailable; using GitHub release metadata' + } + + $latestApi = "https://api.github.com/repos/$Repo/releases/latest" + try { + $latest = Get-HttpJson $Client $latestApi + $tag = [string]$latest.tag_name + if ($tag -match '^@pythoughts/pythinker-code@(\d+\.\d+\.\d+)$') { + return $Matches[1] + } + Stop-Installer "could not parse latest release tag '$tag' from GitHub" + } catch { + Stop-Installer "could not resolve the latest version: $($_.Exception.Message)" + } + } + + function Wait-ReleaseAssets($Client, [string]$ResolvedVersion, [string]$AssetName) { + $api = "https://api.github.com/repos/$Repo/releases/tags/$(Get-EncodedReleaseTag $ResolvedVersion)" + $delay = 4 + $elapsed = 0 + $maxElapsed = 360 + $frame = 0 + $lastError = $null + + while ($true) { + try { + $release = Get-HttpJson $Client $api -AllowNotFound + if (Test-ReleaseHasAsset $release $AssetName) { + if ($useAnimation) { Write-Host -NoNewline ("`r${CLEAR_LINE}") } + Write-PhaseOk 'Release' 'assets ready' + return + } + } catch { + $lastError = $_.Exception.Message + if ($lastError -match 'HTTP (401|403)') { + Stop-Installer $lastError + } + } + + if ($elapsed -ge $maxElapsed) { + $detail = if ($lastError) { " Last error: $lastError" } else { "" } + Stop-Installer "release assets for $ResolvedVersion were not available after ${maxElapsed}s.$detail" + } + + if ($useAnimation) { + $waitFrames = @('◐', '◓', '◑', '◒') + for ($remaining = $delay; $remaining -gt 0; $remaining--) { + $glyph = $waitFrames[$frame % $waitFrames.Length] + Write-Host -NoNewline ("`r${CLEAR_LINE} ${ACCENT}${glyph}${RESET} Release ${DIM}waiting for assets · retry in ${remaining}s${RESET}") + Start-Sleep -Seconds 1 + $elapsed++ + $frame++ + if ($elapsed -ge $maxElapsed) { break } + } + } else { + Write-Host (" ${SOFT}•${RESET} Release waiting for assets; retrying in ${delay}s") + Start-Sleep -Seconds $delay + $elapsed += $delay + } + + $delay = [Math]::Min($delay * 2, 60) + } + } + + function Read-ExpectedHash([string]$Path, [string]$ExpectedFileName) { + $candidates = @() + + foreach ($line in Get-Content -LiteralPath $Path) { + $trimmed = ([string]$line).Trim() + if (-not $trimmed) { continue } + + if ($trimmed -match '^(?[A-Fa-f0-9]{64})\s+\*?(?.+?)\s*$') { + $candidates += [pscustomobject]@{ + Hash = $Matches.hash.ToLowerInvariant() + Name = $Matches.name.Trim() + } + continue + } + + if ($trimmed -match '^SHA256\s*\((?.+?)\)\s*=\s*(?[A-Fa-f0-9]{64})$') { + $candidates += [pscustomobject]@{ + Hash = $Matches.hash.ToLowerInvariant() + Name = $Matches.name.Trim() + } + continue + } + + if ($trimmed -match '^(?[A-Fa-f0-9]{64})$') { + $candidates += [pscustomobject]@{ + Hash = $Matches.hash.ToLowerInvariant() + Name = $null + } + } + } + + $namedMatches = @($candidates | Where-Object { + $_.Name -and ([System.IO.Path]::GetFileName([string]$_.Name) -ieq $ExpectedFileName) + }) + + if ($namedMatches.Count -eq 1) { return [string]$namedMatches[0].Hash } + + $unnamedMatches = @($candidates | Where-Object { -not $_.Name }) + if ($candidates.Count -eq 1 -and $unnamedMatches.Count -eq 1) { + return [string]$unnamedMatches[0].Hash + } + + Stop-Installer "checksum file did not contain a SHA-256 entry for '$ExpectedFileName'" + } + + function Expand-VerifiedBinary([string]$ArchivePath, [string]$DestinationPath) { + try { + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + } catch { + Stop-Installer "ZIP support is unavailable: $($_.Exception.Message)" + } + + $archive = $null + $entryStream = $null + $destinationStream = $null + + try { + $archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) + $files = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) }) + + if ($files.Count -ne 1) { + Stop-Installer "archive must contain exactly one root file named pythinker.exe; found $($files.Count) files" + } + + $entry = $files[0] + $entryPath = ([string]$entry.FullName).Replace('\', '/') + if ($entryPath -cne 'pythinker.exe') { + Stop-Installer "archive must contain exactly one root file named pythinker.exe; found '$entryPath'" + } + if ([long]$entry.Length -le 0) { + Stop-Installer "archive contained an empty pythinker.exe" + } + + $entryStream = $entry.Open() + $destinationStream = [System.IO.File]::Open( + $DestinationPath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None + ) + $entryStream.CopyTo($destinationStream) + $destinationStream.Flush($true) + } finally { + if ($null -ne $destinationStream) { $destinationStream.Dispose() } + if ($null -ne $entryStream) { $entryStream.Dispose() } + if ($null -ne $archive) { $archive.Dispose() } + } + } + + function Move-FileWithRetry( + [string]$Source, + [string]$Destination, + [string]$Description, + [int]$Attempts = 6 + ) { + $lastError = $null + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + [System.IO.File]::Move($Source, $Destination) + return + } catch { + $lastError = $_.Exception.Message + if ($attempt -lt $Attempts) { + Start-Sleep -Milliseconds ([Math]::Min(1500, 200 * $attempt)) + } + } + } + throw "$Description failed after $Attempts attempts: $lastError" + } + + function Remove-FileWithRetry([string]$Path, [int]$Attempts = 5) { + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + if (-not (Test-Path -LiteralPath $Path)) { return $true } + try { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + return $true + } catch { + if ($attempt -lt $Attempts) { Start-Sleep -Milliseconds (250 * $attempt) } + } + } + return -not (Test-Path -LiteralPath $Path) + } + + function Repair-InterruptedInstall([string]$BinaryPath) { + $directory = [System.IO.Path]::GetDirectoryName($BinaryPath) + $leaf = [System.IO.Path]::GetFileName($BinaryPath) + $backups = @(Get-ChildItem -LiteralPath $directory -Filter "$leaf.old-*" -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTimeUtc -Descending) + + if (-not (Test-Path -LiteralPath $BinaryPath) -and $backups.Count -gt 0) { + Move-FileWithRetry $backups[0].FullName $BinaryPath 'recovery of the previous executable' + Write-PhaseOk 'Recovery' 'restored an interrupted prior update' + $backups = @($backups | Select-Object -Skip 1) + } + + if (Test-Path -LiteralPath $BinaryPath) { + foreach ($backup in $backups) { + [void](Remove-FileWithRetry $backup.FullName 2) + } + } + + foreach ($stale in Get-ChildItem -LiteralPath $directory -Filter "$leaf.new-*" -File -ErrorAction SilentlyContinue) { + [void](Remove-FileWithRetry $stale.FullName 2) + } + } + + function Normalize-PathEntry([string]$PathEntry) { + if ([string]::IsNullOrWhiteSpace($PathEntry)) { return "" } + + $clean = $PathEntry.Trim().Trim('"') + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + try { $expanded = [System.IO.Path]::GetFullPath($expanded) } catch {} + return $expanded.TrimEnd([char[]]@('\', '/')) + } + + function Test-PathContains([string]$PathValue, [string]$Entry) { + $normalizedEntry = Normalize-PathEntry $Entry + foreach ($candidate in ($PathValue -split ';')) { + if ((Normalize-PathEntry $candidate) -ieq $normalizedEntry) { return $true } + } + return $false + } + + function Add-InstallDirectoryToPath([string]$InstallDirectory) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $added = $false + + if (-not (Test-PathContains $userPath $InstallDirectory)) { + $newPath = if ($userPath) { "$InstallDirectory;$userPath" } else { $InstallDirectory } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + $added = $true + } + + if (-not (Test-PathContains $env:PATH $InstallDirectory)) { + $env:PATH = "$InstallDirectory;$env:PATH" + } + + return $added + } + + function Get-NativeArchitecture { + try { + $registry = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction Stop + if ($registry.PROCESSOR_ARCHITECTURE) { return [string]$registry.PROCESSOR_ARCHITECTURE } + } catch {} + + if ($env:PROCESSOR_ARCHITEW6432) { return [string]$env:PROCESSOR_ARCHITEW6432 } + return [string]$env:PROCESSOR_ARCHITECTURE + } + + function Print-Intro([string]$ResolvedVersion, [string]$PlatformDisplay, [string]$AssetName, [string]$Action) { + Write-Logo + Write-MetadataRow 'Version' $ResolvedVersion + Write-MetadataRow 'Platform' $PlatformDisplay + Write-MetadataRow 'Package' $AssetName + Write-MetadataRow 'Action' $Action + Write-Host "" + } + + function Print-Done([string]$ResolvedVersion, [string]$BinaryPath, [bool]$PathWasAdded) { + $separatorWidth = [Math]::Max(36, [Math]::Min(58, (Get-TerminalWidth) - 4)) + $separator = "─" * $separatorWidth + + Write-Host "" + Write-Host " ${BAR}${separator}${RESET}" + Write-Host " ${ACCENT}${BOLD}✓ Pythinker Code $ResolvedVersion is ready${RESET}" + Write-Host "" + Write-Host " ${DIM}Run${RESET} ${BOLD}pythinker${RESET}" + Write-Host " ${DIM}Installed${RESET} $BinaryPath" + if ($PathWasAdded) { + Write-Host " ${DIM}PATH${RESET} Added for this user and this session" + } else { + Write-Host " ${DIM}PATH${RESET} Already configured" + } + Write-Host " ${BAR}${separator}${RESET}" + Write-Host "" + } + + try { + if ($ShowHelp) { + Show-Usage + return + } + + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + Stop-Installer "this installer is for Windows. Use: curl -fsSL $InstallShUrl | bash" + } + + $httpClient = New-InstallerHttpClient + + $resolvedVersion = ([string]$RequestedVersion).Trim() + if ($resolvedVersion.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { + $resolvedVersion = $resolvedVersion.Substring(1) + } + if (-not $resolvedVersion) { + $resolvedVersion = Get-LatestVersion $httpClient + } + if (-not (Test-Version $resolvedVersion)) { + Stop-Installer "invalid version '$resolvedVersion'; expected X.Y.Z" + } + + $nativeArchitecture = (Get-NativeArchitecture).ToUpperInvariant() + switch ($nativeArchitecture) { + 'ARM64' { $archLabel = 'arm64' } + 'AMD64' { $archLabel = 'x64' } + default { Stop-Installer "unsupported Windows architecture '$nativeArchitecture' (need x64 or arm64)" } + } + + $localAppData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) + if (-not $localAppData) { $localAppData = $env:LOCALAPPDATA } + if (-not $localAppData) { Stop-Installer 'could not resolve LOCALAPPDATA' } + + $installDir = Join-Path $localAppData 'Programs\Pythinker' + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $targetPath = Join-Path $installDir 'pythinker.exe' + + $mutexUser = ([Environment]::UserName -replace '[^A-Za-z0-9_.-]', '_') + $mutexName = "Local\PythinkerCodeInstaller-$mutexUser" + $installMutex = New-Object System.Threading.Mutex($false, $mutexName) + try { + $mutexHeld = $installMutex.WaitOne(0) + } catch [System.Threading.AbandonedMutexException] { + $mutexHeld = $true + } + if (-not $mutexHeld) { + Stop-Installer 'another Pythinker installer or update is already running' + } + + Repair-InterruptedInstall $targetPath + $action = if (Test-Path -LiteralPath $targetPath) { 'Upgrade' } else { 'Install' } + + $asset = "pythinker-code-win32-$archLabel.zip" + $baseUrl = "https://github.com/$Repo/releases/download/$(Get-EncodedReleaseTag $resolvedVersion)" + $installerUrl = "$baseUrl/$asset" + $shaUrl = "$installerUrl.sha256" + + Print-Intro $resolvedVersion "Windows $archLabel" $asset $action + Wait-ReleaseAssets $httpClient $resolvedVersion $asset + + $tempRoot = [System.IO.Path]::GetTempPath() + $tempDir = Join-Path $tempRoot ("pythinker-install-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tempDir | Out-Null + $installerPath = Join-Path $tempDir $asset + $shaPath = "$installerPath.sha256" + + Download-File $httpClient $installerUrl $installerPath 'Download' + $checksumText = Get-HttpText $httpClient $shaUrl 'Checksum' + [System.IO.File]::WriteAllText($shaPath, $checksumText, [System.Text.Encoding]::ASCII) + + $expectedHash = Read-ExpectedHash $shaPath $asset + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installerPath).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + Stop-Installer "SHA-256 mismatch: expected $expectedHash, got $actualHash" + } + Write-PhaseOk 'Verify' ("SHA-256 {0}…" -f $actualHash.Substring(0, 12)) + + $transactionId = [System.Guid]::NewGuid().ToString('N') + $stagingBinary = Join-Path $installDir "pythinker.exe.new-$transactionId" + Expand-VerifiedBinary $installerPath $stagingBinary + + if (Test-Path -LiteralPath $targetPath) { + $backupBinary = Join-Path $installDir "pythinker.exe.old-$transactionId" + try { + Move-FileWithRetry $targetPath $backupBinary 'moving the existing executable aside' + } catch { + Stop-Installer "could not prepare the current installation for update: $($_.Exception.Message)" + } + } + + try { + Move-FileWithRetry $stagingBinary $targetPath 'installing the new executable' + $stagingBinary = $null + } catch { + $installError = $_.Exception.Message + $rollbackError = $null + + # Roll back the previous executable whenever the new same-volume rename + # cannot complete. The user is never intentionally left without a binary. + if ($backupBinary -and (Test-Path -LiteralPath $backupBinary) -and -not (Test-Path -LiteralPath $targetPath)) { + try { + Move-FileWithRetry $backupBinary $targetPath 'rollback of the previous executable' + $backupBinary = $null + } catch { + $rollbackError = $_.Exception.Message + } + } + + if ($rollbackError) { + Stop-Installer "could not install the new executable ($installError); rollback also failed ($rollbackError)" + } + Stop-Installer "could not install the new executable: $installError" + } + + if ($backupBinary -and (Test-Path -LiteralPath $backupBinary)) { + [void](Remove-FileWithRetry $backupBinary 5) + if (-not (Test-Path -LiteralPath $backupBinary)) { $backupBinary = $null } + } + Write-PhaseOk 'Install' $targetPath + + $pathWasAdded = Add-InstallDirectoryToPath $installDir + if ($pathWasAdded) { + Write-PhaseOk 'PATH' 'added for this user' + } else { + Write-PhaseOk 'PATH' 'already configured' + } + + Print-Done $resolvedVersion $targetPath $pathWasAdded + } finally { + if ($useAnimation) { + Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") + } + + if ($null -ne $httpClient) { $httpClient.Dispose() } + + if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + + if ($stagingBinary -and (Test-Path -LiteralPath $stagingBinary)) { + [void](Remove-FileWithRetry $stagingBinary 2) + } + + # A backup is safe to remove only after the target exists. If rollback did + # not complete, preserve the backup for manual recovery instead of deleting it. + if ($backupBinary -and $targetPath -and (Test-Path -LiteralPath $targetPath) -and (Test-Path -LiteralPath $backupBinary)) { + [void](Remove-FileWithRetry $backupBinary 2) + } + + if ($mutexHeld -and $null -ne $installMutex) { + try { $installMutex.ReleaseMutex() } catch {} + } + if ($null -ne $installMutex) { $installMutex.Dispose() } + + if ($null -ne $previousOutputEncoding) { + try { [Console]::OutputEncoding = $previousOutputEncoding } catch {} + } + if ($null -ne $previousSecurityProtocol) { + try { [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol } catch {} + } + } +} $Version ([bool]$Help) diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh new file mode 100755 index 00000000..9d5998d0 --- /dev/null +++ b/apps/site/public/install.sh @@ -0,0 +1,911 @@ +#!/usr/bin/env bash +# Pythinker Code — polished native curl-bash installer. +# +# Downloads the native single-file binary (Node SEA) for the current OS and +# architecture, verifies its SHA-256 checksum, and installs it at: +# ~/.local/bin/pythinker +# +# Usage: +# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash +# +# Pin a version: +# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 +# +# Choose an install prefix: +# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker +# +# Supported release targets: +# linux-x64, linux-arm64, darwin-arm64, darwin-x64 +# +# Windows: +# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex +set -euo pipefail + +VERSION="" +INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" +NO_COLOR="${NO_COLOR:-}" + +REPO="Pythoughts-labs/pythinker-code" +CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" + +# Operational globals are populated by main(). Keeping rendering helpers at +# file scope makes the installer sourceable for regression tests and tooling. +target="" +platform_display="" +tag_encoded="" +archive="" +archive_url="" +sha_url="" +bin_dir="" +install_path="" +TMP_DIR="" +DOWNLOAD_PID="" + +# UI globals are initialized to empty so helper functions are safe before +# _init_ui is called (for example, when the file is sourced by a test). +_anim="" +_cursor_hidden="" +ROBOT="" +FACE="" +ACCENT="" +TIP="" +EYE="" +SUCCESS="" +WARNING="" +ERROR_COLOR="" +MUTED="" +BORDER="" +BOLD="" +DIM="" +RESET="" + +usage() { + cat <<'EOF_USAGE' +Pythinker Code — native curl-bash installer. + +Downloads the native single-file binary for your OS and architecture, +verifies its SHA-256 checksum, and installs it at: + ~/.local/bin/pythinker + +Usage: + curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash + +Pin a specific version: + curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 + +Use a custom install prefix (default: $HOME/.local): + curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker + +Supported targets: + linux-x64 Linux x86_64 + linux-arm64 Linux ARM64 + darwin-arm64 macOS Apple Silicon + darwin-x64 macOS Intel + +Environment: + PYTHINKER_INSTALL_PREFIX Default install prefix + PYTHINKER_NO_ANIMATION Disable terminal animation when non-empty + PYTHINKER_TERM_WIDTH Override detected width + NO_COLOR Disable ANSI colors and animation + +Windows: + irm https://code.pythinker.com/pythinker-code/install.ps1 | iex +EOF_USAGE +} + +_parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --version) + [[ -n "${2:-}" ]] || { + printf '%s\n' '--version requires a value' >&2 + return 2 + } + VERSION="$2" + shift 2 + ;; + --prefix) + [[ -n "${2:-}" ]] || { + printf '%s\n' '--prefix requires a value' >&2 + return 2 + } + INSTALL_PREFIX="$2" + shift 2 + ;; + -h|--help) + usage + return 10 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + return 2 + ;; + esac + done +} + +_init_ui() { + # Reset first so repeated calls while sourced are deterministic. + _anim="" + ROBOT=""; FACE=""; ACCENT=""; TIP=""; EYE=""; SUCCESS="" + WARNING=""; ERROR_COLOR=""; MUTED=""; BORDER="" + BOLD=""; DIM=""; RESET="" + + if [[ -t 1 && -z "$NO_COLOR" && "${TERM:-}" != "dumb" ]]; then + # Terminal-default foreground plus restrained neutral/pastel accents. + ROBOT=$'\033[38;5;248m' + FACE=$'\033[39m' + ACCENT=$'\033[38;5;141m' + TIP=$'\033[38;5;173m' + EYE=$'\033[38;5;147m' + SUCCESS=$'\033[38;5;114m' + WARNING=$'\033[38;5;179m' + ERROR_COLOR=$'\033[38;5;203m' + MUTED=$'\033[38;5;245m' + BORDER=$'\033[38;5;245m' + BOLD=$'\033[1m' + DIM=$'\033[2m' + RESET=$'\033[0m' + fi + + if [[ -t 1 \ + && -z "$NO_COLOR" \ + && "${TERM:-}" != "dumb" \ + && -z "${PYTHINKER_NO_ANIMATION:-}" \ + && -z "${CI:-}" ]]; then + _anim=1 + fi +} + +_hide_cursor() { + [[ -n "$_anim" ]] || return 0 + [[ -z "$_cursor_hidden" ]] || return 0 + printf '\033[?25l' + _cursor_hidden=1 +} + +_show_cursor() { + [[ -n "$_cursor_hidden" ]] || return 0 + printf '\033[?25h' + _cursor_hidden="" +} + +_cleanup() { + if [[ -n "$DOWNLOAD_PID" ]] && kill -0 "$DOWNLOAD_PID" 2>/dev/null; then + kill "$DOWNLOAD_PID" 2>/dev/null || true + wait "$DOWNLOAD_PID" 2>/dev/null || true + fi + DOWNLOAD_PID="" + + _show_cursor || true + + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} + +fail() { + _show_cursor || true + if [[ -n "$_anim" ]]; then + _clear_active_line + fi + printf ' %s✗%s %s\n' "$ERROR_COLOR" "$RESET" "$1" >&2 + exit 1 +} + +# The explicit width argument is optional; callers other than _wrap_text omit it +# and rely on detection. +# shellcheck disable=SC2120 +_terminal_columns() { + local explicit="${1:-}" + local detected="" + + if [[ "$explicit" =~ ^[0-9]+$ ]] && (( explicit > 0 )); then + printf '%s' "$explicit" + return 0 + fi + + if [[ "${PYTHINKER_TERM_WIDTH:-}" =~ ^[0-9]+$ ]] \ + && (( PYTHINKER_TERM_WIDTH > 0 )); then + printf '%s' "$PYTHINKER_TERM_WIDTH" + return 0 + fi + + if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] && (( COLUMNS > 0 )); then + printf '%s' "$COLUMNS" + return 0 + fi + + if [[ -t 1 && "${TERM:-}" != "dumb" ]] \ + && command -v tput >/dev/null 2>&1; then + detected="$(tput cols 2>/dev/null || true)" + if [[ "$detected" =~ ^[0-9]+$ ]] && (( detected > 0 )); then + printf '%s' "$detected" + return 0 + fi + fi + + printf '80' +} + +_progress_bar_width() { + local columns="${1:-$(_terminal_columns)}" + local width + + if (( columns >= 80 )); then + width=44 + elif (( columns >= 55 )); then + width=$((columns - 32)) + (( width > 44 )) && width=44 + else + width=0 + fi + + printf '%s' "$width" +} + +_repeat_char() { + local char="$1" count="$2" result="" i + for ((i=0; i 50 )) && width=50 + (( width < 1 )) && width=1 + _repeat_char '─' "$width" +} + +_format_bytes() { + local bytes="${1:-0}" + [[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0 + LC_ALL=C awk -v bytes="$bytes" 'BEGIN { + if (bytes < 1024) { + printf "%d B", bytes + } else if (bytes < 1048576) { + printf "%.1f KB", bytes / 1024 + } else if (bytes < 1073741824) { + printf "%.1f MB", bytes / 1048576 + } else { + printf "%.1f GB", bytes / 1073741824 + } + }' +} + +_format_byte_pair() { + local current="${1:-0}" total="${2:-0}" + [[ "$current" =~ ^[0-9]+$ ]] || current=0 + [[ "$total" =~ ^[0-9]+$ ]] || total=0 + LC_ALL=C awk -v current="$current" -v total="$total" 'BEGIN { + unit = "B"; divisor = 1 + if (total >= 1073741824) { + unit = "GB"; divisor = 1073741824 + } else if (total >= 1048576) { + unit = "MB"; divisor = 1048576 + } else if (total >= 1024) { + unit = "KB"; divisor = 1024 + } + + if (divisor == 1) { + printf "%d/%d %s", current, total, unit + } else { + printf "%.1f/%.1f %s", current / divisor, total / divisor, unit + } + }' +} + +_display_path() { + local path="$1" + if [[ -n "${HOME:-}" && "$path" == "$HOME" ]]; then + printf '~' + elif [[ -n "${HOME:-}" && "$path" == "$HOME/"* ]]; then + printf '~%s' "${path#"$HOME"}" + else + printf '%s' "$path" + fi +} + +_current_file_size() { + local file="$1" + if [[ -f "$file" ]]; then + wc -c < "$file" | tr -d '[:space:]' + else + printf '0' + fi +} + +_content_length() { + local url="$1" + command -v curl >/dev/null 2>&1 || return 1 + curl -fsIL "$url" 2>/dev/null \ + | awk 'tolower($1) == "content-length:" { + gsub("\r", "", $2) + bytes = $2 + } + END { + if (bytes ~ /^[0-9]+$/) print bytes + }' +} + +_download_percent() { + local output="$1" total="$2" size percent + [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) || return 1 + size="$(_current_file_size "$output")" + [[ "$size" =~ ^[0-9]+$ ]] || size=0 + percent=$((size * 100 / total)) + (( percent > 99 )) && percent=99 + (( percent < 0 )) && percent=0 + printf '%s' "$percent" +} + +_clear_active_line() { + [[ -n "$_anim" ]] || return 0 + printf '\r\033[2K' +} + +_render_progress_determinate() { + local percent="$1" current="$2" total="$3" frame="$4" + local columns width filled empty filled_bar empty_bar pair + + [[ "$percent" =~ ^[0-9]+$ ]] || percent=0 + (( percent > 100 )) && percent=100 + (( percent < 0 )) && percent=0 + + columns="$(_terminal_columns)" + width="$(_progress_bar_width "$columns")" + pair="$(_format_byte_pair "$current" "$total")" + + _clear_active_line + + if (( width == 0 )); then + # Below 55 columns, keep the display percentage-only to avoid wrapping. + printf ' %s%s%s Downloading %3d%%' \ + "$ACCENT" "$frame" "$RESET" "$percent" + return 0 + fi + + filled=$((percent * width / 100)) + empty=$((width - filled)) + filled_bar="$(_repeat_char '█' "$filled")" + empty_bar="$(_repeat_char '░' "$empty")" + + printf ' %s%s%s Downloading %s%s%s%s%s%s %3d%%' \ + "$ACCENT" "$frame" "$RESET" \ + "$ACCENT" "$filled_bar" "$RESET" \ + "$BORDER" "$empty_bar" "$RESET" \ + "$percent" + + # At 80 columns the 44-cell bar fits, but byte details can wrap. Add them + # only when there is enough room for the largest common value pair. + if (( columns >= 88 )); then + printf ' %s' "$pair" + fi +} + +_render_progress_indeterminate() { + local frame="$1" current="$2" + local columns received + columns="$(_terminal_columns)" + received="$(_format_bytes "$current")" + _clear_active_line + + if (( columns < 45 )); then + printf ' %s%s%s Downloading %s' \ + "$ACCENT" "$frame" "$RESET" "$received" + else + printf ' %s%s%s Downloading %sReceiving package…%s %s' \ + "$ACCENT" "$frame" "$RESET" "$MUTED" "$RESET" "$received" + fi +} + +_render_waiting() { + local frame="$1" delay="$2" + local columns + columns="$(_terminal_columns)" + _clear_active_line + + if (( columns < 55 )); then + printf ' %s%s%s Waiting; retry in %ss' \ + "$ACCENT" "$frame" "$RESET" "$delay" + else + printf ' %s%s%s Waiting %sRelease assets are publishing; retry in %ss%s' \ + "$ACCENT" "$frame" "$RESET" "$MUTED" "$delay" "$RESET" + fi +} + +status_ok() { + local label="$1" detail="${2:-}" + printf ' %s✓%s %s' "$SUCCESS" "$RESET" "$label" + if [[ -n "$detail" ]]; then + printf ' %s%s%s' "$MUTED" "$detail" "$RESET" + fi + printf '\n' +} + +status_warn() { + local label="$1" detail="${2:-}" + printf ' %s!%s %s' "$WARNING" "$RESET" "$label" + if [[ -n "$detail" ]]; then + printf ' %s%s%s' "$MUTED" "$detail" "$RESET" + fi + printf '\n' +} + +print_logo_art() { + printf ' %s●%s\n' "$TIP" "$RESET" + printf ' %s│%s\n' "$ROBOT" "$RESET" + printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ + "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ + "$TIP" "$RESET" "$ROBOT" "$RESET" \ + "$EYE" "$RESET" "$EYE" "$RESET" \ + "$ROBOT" "$RESET" "$TIP" "$RESET" + printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ + "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" +} + +_print_brand() { + printf '\n %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" + printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" +} + +print_logo_static() { + printf '\n' + print_logo_art + _print_brand +} + +print_logo_animated() { + local delay="${PYTHINKER_LOGO_FRAME_DELAY:-0.07}" + + printf '\n' + _hide_cursor + + # Each micro-animation rewrites only the line currently being composed. + printf ' %s·%s' "$MUTED" "$RESET" + sleep "$delay" + _clear_active_line + printf ' %s●%s\n' "$TIP" "$RESET" + + printf ' %s│%s\n' "$ROBOT" "$RESET" + sleep "$delay" + printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ + "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" + sleep "$delay" + + printf ' %s◖%s%s█%s %s·%s %s·%s %s█%s%s◗%s' \ + "$TIP" "$RESET" "$ROBOT" "$RESET" \ + "$MUTED" "$RESET" "$MUTED" "$RESET" \ + "$ROBOT" "$RESET" "$TIP" "$RESET" + sleep "$delay" + _clear_active_line + printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ + "$TIP" "$RESET" "$ROBOT" "$RESET" \ + "$EYE" "$RESET" "$EYE" "$RESET" \ + "$ROBOT" "$RESET" "$TIP" "$RESET" + + printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ + "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" + sleep "$delay" + + printf '\n %sPYTHINKER CODE%s' "$DIM" "$RESET" + sleep "$delay" + _clear_active_line + printf ' %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" + printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" + + _show_cursor +} + +print_intro() { + local destination + destination="$(_display_path "$install_path")" + + if [[ -n "$_anim" ]]; then + print_logo_animated + else + print_logo_static + fi + + printf ' %s%-12s%s %s\n' "$MUTED" 'Version' "$RESET" "$VERSION" + printf ' %s%-12s%s %s\n' "$MUTED" 'Platform' "$RESET" "$platform_display" + printf ' %s%-12s%s %s\n' "$MUTED" 'Destination' "$RESET" "$destination" + printf '\n' +} + +print_done() { + local sep destination + sep="$(_separator)" + destination="$(_display_path "$install_path")" + + printf '\n %s%s%s\n\n' "$BORDER" "$sep" "$RESET" + printf ' %s%sReady to think, plan, and build.%s\n\n' \ + "$BOLD" "$FACE" "$RESET" + printf ' %sInstalled at%s %s\n' "$MUTED" "$RESET" "$destination" + printf ' %sStart with%s %s%s$ pythinker%s\n\n' \ + "$MUTED" "$RESET" "$BOLD" "$ACCENT" "$RESET" +} + +_fetch() { + local url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$url" + else + return 127 + fi +} + +_download_quiet() { + local url="$1" output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" + else + return 127 + fi +} + +_start_download() { + local url="$1" output="$2" + DOWNLOAD_PID="" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" & + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" & + else + return 127 + fi + + DOWNLOAD_PID=$! +} + +# One download attempt with a live progress display. Returns non-zero on +# transport failure, an empty file, or a size short of Content-Length. +_download_attempt_with_progress() { + local url="$1" output="$2" + local total="" pid="" current=0 percent=0 i=0 frame_index=0 + local -a frames=('◐' '◓' '◑' '◒') + + rm -f "$output" + + if [[ -z "$_anim" ]]; then + _download_quiet "$url" "$output" || return 1 + _validate_download "$output" "" || return 1 + current="$(_current_file_size "$output")" + status_ok 'Download complete' "$(_format_bytes "$current")" + return 0 + fi + + if command -v curl >/dev/null 2>&1; then + total="$(_content_length "$url" || true)" + fi + + _start_download "$url" "$output" || return $? + pid="$DOWNLOAD_PID" + _hide_cursor + + while kill -0 "$pid" 2>/dev/null; do + frame_index=$((i % 4)) + current="$(_current_file_size "$output")" + + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then + percent="$(_download_percent "$output" "$total" || printf '0')" + _render_progress_determinate \ + "$percent" "$current" "$total" "${frames[$frame_index]}" + else + _render_progress_indeterminate "${frames[$frame_index]}" "$current" + fi + + sleep 0.12 + i=$((i + 1)) + done + + if ! wait "$pid"; then + DOWNLOAD_PID="" + _show_cursor + _clear_active_line + return 1 + fi + DOWNLOAD_PID="" + + if ! _validate_download "$output" "$total"; then + _show_cursor + _clear_active_line + return 1 + fi + + current="$(_current_file_size "$output")" + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then + _render_progress_determinate 100 "$current" "$total" '✓' + printf '\n' + else + _clear_active_line + fi + + _show_cursor + status_ok 'Download complete' "$(_format_bytes "$current")" +} + +# Reject empty downloads, and short downloads when Content-Length is known. +# A truncated archive would fail checksum verification anyway, but catching +# it here lets the retry loop recover instead of aborting the install. +_validate_download() { + local output="$1" total="${2:-}" + local size + size="$(_current_file_size "$output")" + [[ "$size" =~ ^[0-9]+$ ]] && (( size > 0 )) || return 1 + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) && (( size != total )); then + return 1 + fi + return 0 +} + +_download_with_progress() { + local url="$1" output="$2" + local attempt delay + + for attempt in 1 2 3; do + if _download_attempt_with_progress "$url" "$output"; then + return 0 + fi + rm -f "$output" + if (( attempt < 3 )); then + delay=$((2 ** (attempt - 1))) + status_warn 'Download failed' "retry $((attempt + 1))/3 in ${delay}s" + sleep "$delay" + fi + done + return 1 +} + +_download_quiet_with_retry() { + local label="$1" url="$2" output="$3" + local attempt delay + + for attempt in 1 2 3; do + if _download_quiet "$url" "$output" && _validate_download "$output" ""; then + return 0 + fi + rm -f "$output" + if (( attempt < 3 )); then + delay=$((2 ** (attempt - 1))) + status_warn "$label failed" "retry $((attempt + 1))/3 in ${delay}s" + sleep "$delay" + fi + done + return 1 +} + +_detect_target() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os/$arch" in + Linux/x86_64|Linux/amd64) + target='linux-x64' + platform_display='Linux · x86_64' + ;; + Linux/aarch64|Linux/arm64) + target='linux-arm64' + platform_display='Linux · ARM64' + ;; + Darwin/arm64) + target='darwin-arm64' + platform_display='macOS · Apple Silicon' + ;; + Darwin/x86_64) + target='darwin-x64' + platform_display='macOS · Intel' + ;; + MINGW*/*|MSYS*/*|CYGWIN*/*) + fail $'On Windows, use the PowerShell installer:\n powershell -c "irm https://code.pythinker.com/pythinker-code/install.ps1 | iex"' + ;; + *) + fail "unsupported target: $os/$arch" + ;; + esac +} + +_resolve_version() { + local api payload + + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || fail 'need curl or wget to fetch release metadata' + + if [[ -z "$VERSION" ]]; then + VERSION="$(_fetch "$CDN_LATEST_URL" 2>/dev/null \ + | tr -d '[:space:]' || true)" + + if ! printf '%s' "$VERSION" \ + | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + api="https://api.github.com/repos/${REPO}/releases/latest" + payload="$(_fetch "$api")" \ + || fail "could not reach $CDN_LATEST_URL or $api" + VERSION="$(printf '%s' "$payload" \ + | sed -nE 's/.*"tag_name": *"@pythoughts\/pythinker-code@([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' \ + | head -n 1)" + fi + fi + + printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "invalid version '$VERSION'; expected X.Y.Z" +} + +release_has_assets() { + local api body + api="https://api.github.com/repos/${REPO}/releases/tags/${tag_encoded}" + body="$(_fetch "$api" 2>/dev/null)" || return 1 + printf '%s' "$body" | grep -Fq "\"${archive}\"" \ + && printf '%s' "$body" | grep -Fq "\"${archive}.sha256\"" +} + +_wait_for_release_assets() { + local attempt=0 delay=4 elapsed=0 max_elapsed=360 + local -a frames=('◐' '◓' '◑' '◒') + + until release_has_assets; do + if (( elapsed >= max_elapsed )); then + fail "release assets for ${VERSION} are unavailable after about ${max_elapsed}s: ${archive_url} +The release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" + fi + + if [[ -n "$_anim" ]]; then + _render_waiting "${frames[$((attempt % 4))]}" "$delay" + else + printf ' Waiting for release assets; retrying in %ss\n' "$delay" + fi + + sleep "$delay" + if [[ -n "$_anim" ]]; then + _clear_active_line + fi + + attempt=$((attempt + 1)) + elapsed=$((elapsed + delay)) + delay=$((delay * 2)) + (( delay > 120 )) && delay=120 + done + + if [[ -n "$_anim" ]] && (( attempt > 0 )); then + _clear_active_line + fi +} + +_verify_checksum() { + local checksum_file="$1" payload_file="$2" + local expected actual + + expected="$(awk 'NR == 1 {print $1}' "$checksum_file" \ + | tr '[:upper:]' '[:lower:]')" + printf '%s' "$expected" | grep -Eq '^[0-9a-f]{64}$' \ + || fail 'the release checksum file is malformed' + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$payload_file" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$payload_file" | awk '{print $1}')" + else + fail 'need sha256sum or shasum to verify the download' + fi + + actual="$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" + [[ "$expected" == "$actual" ]] \ + || fail "SHA-256 mismatch: expected $expected, got $actual" + + status_ok 'Checksum verified' +} + +_extract_and_install() { + local payload="$TMP_DIR/pythinker" + + mkdir -p "$bin_dir" + # Sweep staged leftovers from a previous interrupted run. + rm -f "$install_path".tmp.* 2>/dev/null || true + + if command -v unzip >/dev/null 2>&1; then + unzip -oq "$TMP_DIR/$archive" -d "$TMP_DIR" + elif command -v tar >/dev/null 2>&1 \ + && tar -tf "$TMP_DIR/$archive" >/dev/null 2>&1; then + tar -C "$TMP_DIR" -xf "$TMP_DIR/$archive" + else + fail "need unzip (or bsdtar) to extract $archive" + fi + + [[ -f "$payload" ]] \ + || fail "archive did not contain a regular file named 'pythinker'" + command -v install >/dev/null 2>&1 \ + || fail "need the 'install' command to place the executable" + + # Stage next to the target, then rename into place. `install` alone + # truncate-writes the destination: overwriting a currently running + # `pythinker` fails with ETXTBSY on Linux and can leave a half-written + # binary on any platform. rename() replaces the path atomically and is + # legal even while the old inode is still executing. + local staged="$install_path.tmp.$$" + if ! install -m 0755 "$payload" "$staged"; then + rm -f "$staged" + fail "could not stage the executable in $(_display_path "$bin_dir")" + fi + if ! mv -f "$staged" "$install_path"; then + rm -f "$staged" + fail "could not move the executable into place at $(_display_path "$install_path")" + fi + status_ok 'Installed successfully' "$(_display_path "$install_path")" +} + +_print_path_guidance() { + case ":$PATH:" in + *":$bin_dir:"*) + return 0 + ;; + esac + + printf '\n' + status_warn \ + 'PATH update required' \ + "$(_display_path "$bin_dir") is not currently on PATH" + printf ' %sBash or Zsh%s\n' "$MUTED" "$RESET" + # $PATH stays literal on purpose — this line is shell config for the user to copy. + # shellcheck disable=SC2016 + printf ' export PATH="%s:$PATH"\n' "$bin_dir" + printf ' %sFish%s\n' "$MUTED" "$RESET" + printf ' fish_add_path "%s"\n' "$bin_dir" +} + +main() { + local parse_status=0 + + _parse_args "$@" || parse_status=$? + if (( parse_status == 10 )); then + return 0 + elif (( parse_status != 0 )); then + return "$parse_status" + fi + + _init_ui + trap _cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + _detect_target + _resolve_version + + tag_encoded="%40pythoughts%2Fpythinker-code%40${VERSION}" + archive="pythinker-code-${target}.zip" + archive_url="https://github.com/${REPO}/releases/download/${tag_encoded}/${archive}" + sha_url="${archive_url}.sha256" + bin_dir="$INSTALL_PREFIX/bin" + install_path="$bin_dir/pythinker" + + print_intro + _wait_for_release_assets + + TMP_DIR="$(mktemp -d -t pythinker-install.XXXXXX)" + _download_with_progress "$archive_url" "$TMP_DIR/$archive" \ + || fail "download failed after 3 attempts: $archive_url" + _download_quiet_with_retry 'Checksum download' "$sha_url" "$TMP_DIR/$archive.sha256" \ + || fail "checksum download failed after 3 attempts: $sha_url" + + _verify_checksum "$TMP_DIR/$archive.sha256" "$TMP_DIR/$archive" + _extract_and_install + _print_path_guidance + print_done +} + +# `curl … | bash` feeds the script over stdin, where BASH_SOURCE is empty and +# $0 is "bash". Defaulting to $0 keeps the piped install (the documented entry +# point) running main, still runs main when the file is executed directly, and +# still skips it when the script is sourced. +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + main "$@" +fi diff --git a/apps/site/public/logo.png b/apps/site/public/logo.png new file mode 100644 index 00000000..4fdf646b Binary files /dev/null and b/apps/site/public/logo.png differ diff --git a/apps/site/public/og.jpg b/apps/site/public/og.jpg new file mode 100644 index 00000000..01ad47a9 Binary files /dev/null and b/apps/site/public/og.jpg differ diff --git a/apps/site/public/pythinker_animated.svg b/apps/site/public/pythinker_animated.svg new file mode 100644 index 00000000..bf23b5bc --- /dev/null +++ b/apps/site/public/pythinker_animated.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/site/public/robots.txt b/apps/site/public/robots.txt index ddb160b4..cbe11854 100644 --- a/apps/site/public/robots.txt +++ b/apps/site/public/robots.txt @@ -1,32 +1,4 @@ -# Search and user-directed AI access are allowed; release artifacts are not crawl targets. -User-agent: OAI-SearchBot -User-agent: ChatGPT-User -User-agent: Claude-SearchBot -User-agent: Claude-User -User-agent: Amazonbot -User-agent: PerplexityBot -User-agent: Perplexity-User -User-agent: Applebot -Allow: / -Disallow: /pythinker-code/ -Content-Signal: ai-train=no, search=yes, ai-input=yes - -# Crawlers associated with model training are not allowed. -User-agent: GPTBot -User-agent: ClaudeBot -User-agent: Claude-Web -User-agent: anthropic-ai -User-agent: Google-Extended -User-agent: Bytespider -User-agent: CCBot -User-agent: Applebot-Extended -Disallow: / -Content-Signal: ai-train=no, search=yes, ai-input=yes - -# All other crawlers may index the public site but not release artifacts. User-agent: * Allow: / -Disallow: /pythinker-code/ -Content-Signal: ai-train=no, search=yes, ai-input=yes -Sitemap: https://code.pythinker.com/sitemap.xml +Sitemap: https://pythinker.com/sitemap.xml diff --git a/apps/site/public/vscode_img.jpeg b/apps/site/public/vscode_img.jpeg new file mode 100644 index 00000000..6cd8d4b9 Binary files /dev/null and b/apps/site/public/vscode_img.jpeg differ diff --git a/apps/site/src/App.vue b/apps/site/src/App.vue index a0b42f02..22c531c5 100644 --- a/apps/site/src/App.vue +++ b/apps/site/src/App.vue @@ -57,6 +57,8 @@ const terminalDemos = [ }, ]; +const vscodeInstallCommand = 'code --install-extension pythoughts.pythinker-code'; + const copiedCommand = ref(''); const mobileMenu = ref(null); const menuButton = ref(null); @@ -279,6 +281,47 @@ onUnmounted(() => { +
+
+

VS Code extension

+

The same agent, inside your editor.

+
+
+
+ Pythinker Code running in the VS Code sidebar next to an open editor +
+
+

Install from the Marketplace and Pythinker Code lives in the Activity Bar. It reads your repo, proposes edits in the native diff viewer, and runs commands with your approval.

+
    +
  • Native diffs. Review every proposed change in VS Code's own diff viewer before it lands.
  • +
  • Shared config. Same config.toml, MCP servers, login, and sessions as the terminal app.
  • +
  • Thinking controls. Toggle reasoning or pick a model-supported thinking effort per task.
  • +
+
+ + + Get the extension + +
+ code --install-extension pythoughts.pythinker-code + +
+
+

Requires VS Code 1.100.0 or later. Cursor, Windsurf, and other VS Code forks install the same VSIX.

+
+
+
+

Installation

@@ -1330,6 +1373,104 @@ onUnmounted(() => { opacity: 0.6; } +.vscode-grid { + display: grid; + grid-template-columns: 3fr 2fr; + align-items: stretch; + gap: 48px; + margin-top: 32px; +} + +/* ponytail: the shot fills the card so both columns end on the same line; cover + crops the screenshot's right-hand marketplace rail, which carries no message */ +.vscode-shot { + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: var(--r-lg); + background: var(--terminal-bg); + box-shadow: var(--shadow-terminal); +} + +.vscode-shot img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: left center; +} + +.vscode-copy { + display: flex; + flex-direction: column; + gap: 20px; +} + +.vscode-lead { + color: var(--ink-muted); + font-size: 16px; + line-height: 1.6; +} + +.vscode-points { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0; + list-style: none; + color: var(--ink-muted); + font-size: 14px; + line-height: 1.6; +} + +.vscode-points strong { + color: var(--ink); + font-weight: 600; +} + +.vscode-points code { + padding: 1px 5px; + border-radius: var(--r-sm); + background: var(--surface-2); + color: var(--ink); + font-size: 13px; +} + +.vscode-actions { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} + +.vscode-actions .button-primary { + gap: 8px; +} + +.vscode-actions .button-primary img { + filter: brightness(0) invert(1); +} + +.vscode-command { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 8px; + padding: 4px 4px 4px 12px; + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + background: var(--surface-2); +} + +.vscode-command code { + min-width: 0; + flex: 1; + overflow-x: auto; + color: var(--ink); + font-size: 13px; + white-space: nowrap; +} + .plugin-row { display: flex; min-height: 72px; @@ -1514,7 +1655,8 @@ onUnmounted(() => { @media (max-width: 899px) { .quickstart-grid, - .docs-grid { + .docs-grid, + .vscode-grid { grid-template-columns: 1fr; }