[CmdletBinding()] param( [Parameter()] [ValidateNotNullOrEmpty()] [string]$AssetBase = 'https://askbubble.app/terminal/releases/v0.3.0', [Parameter()] [ValidateNotNullOrEmpty()] [string]$Version = 'v0.3.0', [Parameter()] [string]$Server = 'https://bubble-api-rylan.fly.dev', [Parameter()] [string]$InstallRoot, [Parameter()] [switch]$NoPathUpdate, [Parameter()] [switch]$NoLaunch ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Stop-BubbleInstall { param([Parameter(Mandatory = $true)][string]$Message) throw "install-bubble-terminal: $Message" } function Get-NormalizedFullPath { param([Parameter(Mandatory = $true)][string]$Path) try { return [System.IO.Path]::GetFullPath($Path).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) } catch { Stop-BubbleInstall "invalid filesystem path: $Path" } } function Assert-DirectChildPath { param( [Parameter(Mandatory = $true)][string]$Parent, [Parameter(Mandatory = $true)][string]$Child ) $normalizedParent = Get-NormalizedFullPath $Parent $normalizedChild = Get-NormalizedFullPath $Child $actualParent = Get-NormalizedFullPath ([System.IO.Path]::GetDirectoryName($normalizedChild)) if (-not $actualParent.Equals($normalizedParent, [System.StringComparison]::OrdinalIgnoreCase)) { Stop-BubbleInstall "refusing to modify a path outside $normalizedParent" } } function Assert-NotReparsePoint { param([Parameter(Mandatory = $true)][string]$Path) if (Test-Path -LiteralPath $Path) { $item = Get-Item -LiteralPath $Path -Force if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { Stop-BubbleInstall "refusing to replace a linked or redirected directory: $Path" } } } function Remove-ScopedDirectory { param( [Parameter(Mandatory = $true)][string]$Parent, [Parameter(Mandatory = $true)][string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { return } Assert-DirectChildPath -Parent $Parent -Child $Path Assert-NotReparsePoint -Path $Path Remove-Item -LiteralPath $Path -Recurse -Force } function Write-AtomicTextFile { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Contents, [Parameter(Mandatory = $true)][System.Text.Encoding]$Encoding ) $directory = [System.IO.Path]::GetDirectoryName($Path) $temporaryPath = Join-Path $directory ('.bubble-write-{0}.tmp' -f [System.Guid]::NewGuid().ToString('N')) try { [System.IO.File]::WriteAllText($temporaryPath, $Contents, $Encoding) if (Test-Path -LiteralPath $Path -PathType Leaf) { [System.IO.File]::Replace($temporaryPath, $Path, $null) } elseif (Test-Path -LiteralPath $Path) { Stop-BubbleInstall "expected a file but found another item: $Path" } else { [System.IO.File]::Move($temporaryPath, $Path) } } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force } } } function Invoke-SafeHttpsDownload { param( [Parameter(Mandatory = $true)][System.Uri]$Uri, [Parameter(Mandatory = $true)][string]$OutFile, [Parameter(Mandatory = $true)][long]$MaximumBytes, [Parameter(Mandatory = $true)][int]$TimeoutMilliseconds ) if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne 'https') { Stop-BubbleInstall 'downloads and redirects must use HTTPS' } $currentUri = $Uri $redirects = 0 $response = $null $responseStream = $null $fileStream = $null $downloadSucceeded = $false try { while ($true) { $request = [System.Net.HttpWebRequest]::Create($currentUri) $request.AllowAutoRedirect = $false $request.Method = 'GET' $request.Timeout = $TimeoutMilliseconds $request.ReadWriteTimeout = $TimeoutMilliseconds $request.UserAgent = 'Bubble-Terminal-Installer/1' $response = [System.Net.HttpWebResponse]$request.GetResponse() $statusCode = [int]$response.StatusCode if ($statusCode -ge 300 -and $statusCode -lt 400) { $location = $response.Headers['Location'] $response.Dispose() $response = $null $redirects += 1 if ($redirects -gt 5 -or [string]::IsNullOrWhiteSpace($location)) { Stop-BubbleInstall 'the download returned too many redirects or an invalid redirect' } try { $nextUri = [System.Uri]::new($currentUri, $location) } catch { Stop-BubbleInstall 'the download returned an invalid redirect URL' } if (-not $nextUri.IsAbsoluteUri -or $nextUri.Scheme -ne 'https' -or -not [string]::IsNullOrEmpty($nextUri.UserInfo)) { Stop-BubbleInstall 'the download attempted an unsafe redirect' } $currentUri = $nextUri continue } if ($statusCode -ne 200) { Stop-BubbleInstall "the download returned HTTP status $statusCode" } if ($response.ContentLength -gt $MaximumBytes) { Stop-BubbleInstall 'the download exceeds the installer safety limit' } break } $responseStream = $response.GetResponseStream() $fileStream = [System.IO.File]::Open( $OutFile, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $buffer = [byte[]]::new(81920) [long]$totalBytes = 0 while (($bytesRead = $responseStream.Read($buffer, 0, $buffer.Length)) -gt 0) { if ($totalBytes -gt ($MaximumBytes - $bytesRead)) { Stop-BubbleInstall 'the download exceeds the installer safety limit' } $fileStream.Write($buffer, 0, $bytesRead) $totalBytes += $bytesRead } if ($totalBytes -le 0) { Stop-BubbleInstall 'the download was empty' } $downloadSucceeded = $true } finally { if ($null -ne $fileStream) { $fileStream.Dispose() } if ($null -ne $responseStream) { $responseStream.Dispose() } if ($null -ne $response) { $response.Dispose() } if (-not $downloadSucceeded -and (Test-Path -LiteralPath $OutFile -PathType Leaf)) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue } } } function Test-PathEntryEqual { param( [Parameter(Mandatory = $true)][string]$Left, [Parameter(Mandatory = $true)][string]$Right ) try { $normalizedLeft = Get-NormalizedFullPath ([System.Environment]::ExpandEnvironmentVariables($Left.Trim('"'))) $normalizedRight = Get-NormalizedFullPath ([System.Environment]::ExpandEnvironmentVariables($Right.Trim('"'))) return $normalizedLeft.Equals($normalizedRight, [System.StringComparison]::OrdinalIgnoreCase) } catch { return $false } } function Add-BubbleToUserPath { param([Parameter(Mandatory = $true)][string]$BinDirectory) $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') $entries = if ([string]::IsNullOrWhiteSpace($userPath)) { @() } else { @($userPath.Split(';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } $alreadyPresent = $false foreach ($entry in $entries) { if (Test-PathEntryEqual -Left $entry -Right $BinDirectory) { $alreadyPresent = $true break } } if (-not $alreadyPresent) { $newUserPath = (@($entries) + $BinDirectory) -join ';' [System.Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User') } $currentEntries = if ([string]::IsNullOrWhiteSpace($env:Path)) { @() } else { @($env:Path.Split(';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } $currentHasBin = $false foreach ($entry in $currentEntries) { if (Test-PathEntryEqual -Left $entry -Right $BinDirectory) { $currentHasBin = $true break } } if (-not $currentHasBin) { $env:Path = if ([string]::IsNullOrWhiteSpace($env:Path)) { $BinDirectory } else { "$($env:Path);$BinDirectory" } } return -not $alreadyPresent } function Get-BubbleCommandResolution { $commands = @(Get-Command -Name 'bubble' -ErrorAction SilentlyContinue) if ($commands.Count -eq 0) { return $null } $command = $commands[0] $commandPath = $null if ($command.CommandType.ToString() -in @('Application', 'ExternalScript')) { $commandPath = Get-NormalizedFullPath $command.Path } return [pscustomobject]@{ Path = $commandPath Description = if ($null -ne $commandPath) { $commandPath } else { "$($command.CommandType) '$($command.Name)'" } } } function Test-BubbleCommandResolution { param( [Parameter()][AllowNull()][object]$Resolution, [Parameter(Mandatory = $true)][string]$ExpectedPath ) return $null -ne $Resolution -and $null -ne $Resolution.Path -and $Resolution.Path.Equals( (Get-NormalizedFullPath $ExpectedPath), [System.StringComparison]::OrdinalIgnoreCase ) } if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Stop-BubbleInstall 'this installer supports Windows only; use install-bubble-terminal.sh on Linux or macOS' } if ($AssetBase -like '__BUBBLE_DEFAULT_*') { Stop-BubbleInstall 'this source copy has no default asset location; pass -AssetBase https://host/path/to/release or use a published Bubble Terminal installer' } if ($Version -like '__BUBBLE_DEFAULT_*') { Stop-BubbleInstall 'this source copy has no default release version; pass an exact tag such as -Version v0.1.0 or use a published Bubble Terminal installer' } $assetBaseText = $AssetBase.Trim() if ($assetBaseText.Length -gt 2048 -or $assetBaseText -notmatch '^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~-]+)*/?$') { Stop-BubbleInstall '-AssetBase must be an exact HTTPS release directory containing only safe URL path characters' } $assetBaseRemainder = $assetBaseText.Substring('https://'.Length) $assetPathIndex = $assetBaseRemainder.IndexOf('/') if ($assetPathIndex -ge 0) { foreach ($segment in $assetBaseRemainder.Substring($assetPathIndex + 1).Split('/')) { if ($segment -in @('.', '..')) { Stop-BubbleInstall '-AssetBase cannot contain dot path segments' } } } try { $assetBaseUri = [System.Uri]$assetBaseText } catch { Stop-BubbleInstall '-AssetBase is not a valid HTTPS URL' } $assetHostKind = [System.Uri]::CheckHostName($assetBaseUri.DnsSafeHost) if (-not $assetBaseUri.IsAbsoluteUri -or $assetBaseUri.Scheme -ne 'https' -or [string]::IsNullOrWhiteSpace($assetBaseUri.Host) -or $assetHostKind -eq [System.UriHostNameType]::Unknown -or -not [string]::IsNullOrEmpty($assetBaseUri.UserInfo) -or -not [string]::IsNullOrEmpty($assetBaseUri.Query) -or -not [string]::IsNullOrEmpty($assetBaseUri.Fragment) -or (-not $assetBaseUri.IsDefaultPort -and ($assetBaseUri.Port -lt 1 -or $assetBaseUri.Port -gt 65535))) { Stop-BubbleInstall '-AssetBase must be an exact HTTPS release directory without credentials, a query, or a fragment' } $normalizedAssetBase = $assetBaseUri.AbsoluteUri.TrimEnd('/') if ($Version.Length -gt 128 -or $Version.EndsWith('.') -or $Version -notmatch '^v[A-Za-z0-9][A-Za-z0-9._-]*$') { Stop-BubbleInstall 'invalid -Version; expected an exact release tag such as v0.1.0' } if ([string]::IsNullOrWhiteSpace($Server) -or $Server -like '__BUBBLE_DEFAULT_*') { $Server = Read-Host 'Bubble service URL (https://bubble-api.example.com)' } if ([string]::IsNullOrWhiteSpace($Server)) { Stop-BubbleInstall '-Server is required' } $serverText = $Server.Trim() if ($serverText.Length -gt 2048) { Stop-BubbleInstall '-Server is too long' } try { $serverUri = [System.Uri]$serverText } catch { Stop-BubbleInstall '-Server is not a valid URL' } $hasAuthority = $serverUri.IsAbsoluteUri -and -not [string]::IsNullOrWhiteSpace($serverUri.Host) $serverHostKind = [System.Uri]::CheckHostName($serverUri.DnsSafeHost) $hasForbiddenParts = -not [string]::IsNullOrEmpty($serverUri.UserInfo) -or -not [string]::IsNullOrEmpty($serverUri.Query) -or -not [string]::IsNullOrEmpty($serverUri.Fragment) -or $serverUri.AbsolutePath -ne '/' $isSecure = $hasAuthority -and $serverUri.Scheme -eq 'https' $isLoopbackPlaintext = $hasAuthority -and $serverUri.Scheme -eq 'http' -and $serverUri.Host -in @('127.0.0.1', 'localhost', '::1') -and -not $serverUri.IsDefaultPort $hasInvalidPort = -not $serverUri.IsDefaultPort -and ($serverUri.Port -lt 1 -or $serverUri.Port -gt 65535) if ($serverHostKind -eq [System.UriHostNameType]::Unknown -or $hasForbiddenParts -or $hasInvalidPort -or -not ($isSecure -or $isLoopbackPlaintext)) { Stop-BubbleInstall '-Server must be an origin-only https:// URL; http:// is allowed only for an explicit loopback port, and paths, credentials, queries, and fragments are not allowed' } $normalizedServer = $serverUri.GetLeftPart([System.UriPartial]::Authority) + '/' $nativeArchitecture = if (-not [string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432)) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } if ($nativeArchitecture -in @('AMD64', 'x86_64')) { $platform = 'windows-x64' } elseif ($nativeArchitecture -eq 'ARM64') { $platform = 'windows-arm64' } else { Stop-BubbleInstall 'Bubble Terminal portable releases support x64 and arm64 Windows only' } $customInstallRoot = -not [string]::IsNullOrWhiteSpace($InstallRoot) if (-not $customInstallRoot) { if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { Stop-BubbleInstall 'LOCALAPPDATA is unavailable; pass a user-writable -InstallRoot' } $InstallRoot = Join-Path $env:LOCALAPPDATA 'Bubble Terminal' } $resolvedInstallRoot = Get-NormalizedFullPath $InstallRoot $volumeRoot = [System.IO.Path]::GetPathRoot($resolvedInstallRoot).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) if ($resolvedInstallRoot.Equals($volumeRoot, [System.StringComparison]::OrdinalIgnoreCase)) { Stop-BubbleInstall '-InstallRoot cannot be the root of a drive' } $versionsDirectory = Join-Path $resolvedInstallRoot 'versions' $binDirectory = Join-Path $resolvedInstallRoot 'bin' $versionDirectory = Join-Path $versionsDirectory $Version $shimPath = Join-Path $binDirectory 'bubble.cmd' $installLockPath = Join-Path $resolvedInstallRoot '.install.lock' $hadPreviousShim = $false $previousShimContents = $null Assert-DirectChildPath -Parent $versionsDirectory -Child $versionDirectory Assert-DirectChildPath -Parent $resolvedInstallRoot -Child $installLockPath if (Test-Path -LiteralPath $resolvedInstallRoot) { Assert-NotReparsePoint -Path $resolvedInstallRoot } if (Test-Path -LiteralPath $binDirectory) { Assert-NotReparsePoint -Path $binDirectory } $temporaryParent = Get-NormalizedFullPath ([System.IO.Path]::GetTempPath()) $workDirectory = Join-Path $temporaryParent ('bubble-install-{0}' -f [System.Guid]::NewGuid().ToString('N')) Assert-DirectChildPath -Parent $temporaryParent -Child $workDirectory $archiveName = "bubble-terminal-$Version-$platform.zip" $archivePath = Join-Path $workDirectory $archiveName $checksumsPath = Join-Path $workDirectory 'SHA256SUMS' $extractDirectory = Join-Path $workDirectory 'extracted' $expectedBundleName = "bubble-terminal-$Version" $expectedBundleDirectory = Join-Path $extractDirectory $expectedBundleName $releaseBase = $normalizedAssetBase $utf8NoBom = [System.Text.UTF8Encoding]::new($false) $ascii = [System.Text.ASCIIEncoding]::new() $installSucceeded = $false $pathWasAdded = $false $bubbleCommandReady = $false $bubbleResolutionAfterInstall = $null $stagedVersionDirectory = $null $backupVersionDirectory = $null $hadPreviousVersion = $false $activatedVersion = $false $installLockStream = $null $priorSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol try { $null = New-Item -ItemType Directory -Path $resolvedInstallRoot -Force Assert-NotReparsePoint -Path $resolvedInstallRoot $null = New-Item -ItemType Directory -Path $versionsDirectory -Force $null = New-Item -ItemType Directory -Path $binDirectory -Force Assert-NotReparsePoint -Path $versionsDirectory Assert-NotReparsePoint -Path $binDirectory if (Test-Path -LiteralPath $installLockPath) { Assert-NotReparsePoint -Path $installLockPath } try { $installLockStream = [System.IO.File]::Open( $installLockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) } catch { Stop-BubbleInstall 'another installation may be running, or the installation lock is unavailable' } $hadPreviousShim = Test-Path -LiteralPath $shimPath -PathType Leaf if (Test-Path -LiteralPath $shimPath) { Assert-NotReparsePoint -Path $shimPath if (-not $hadPreviousShim) { Stop-BubbleInstall "refusing to replace a non-file launcher at $shimPath" } $existingShim = [System.IO.File]::ReadAllText($shimPath) if (-not $existingShim.Contains('@REM Bubble Terminal managed launcher')) { Stop-BubbleInstall "refusing to replace an unmanaged launcher at $shimPath" } $previousShimContents = $existingShim } if (-not $NoPathUpdate) { $existingBubbleResolution = Get-BubbleCommandResolution if ($null -ne $existingBubbleResolution -and -not (Test-BubbleCommandResolution -Resolution $existingBubbleResolution -ExpectedPath $shimPath)) { Stop-BubbleInstall "another command named bubble already has priority at $($existingBubbleResolution.Description); remove or rename it before installing Bubble Terminal" } } $null = New-Item -ItemType Directory -Path $workDirectory $null = New-Item -ItemType Directory -Path $extractDirectory [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 Write-Host "Downloading Bubble Terminal $Version for $platform..." Invoke-SafeHttpsDownload ` -Uri ([System.Uri]"$releaseBase/$archiveName") ` -OutFile $archivePath ` -MaximumBytes 536870912 ` -TimeoutMilliseconds 600000 Invoke-SafeHttpsDownload ` -Uri ([System.Uri]"$releaseBase/SHA256SUMS") ` -OutFile $checksumsPath ` -MaximumBytes 4194304 ` -TimeoutMilliseconds 120000 $archiveLength = (Get-Item -LiteralPath $archivePath).Length $checksumsLength = (Get-Item -LiteralPath $checksumsPath).Length if ($archiveLength -le 0 -or $archiveLength -gt 536870912) { Stop-BubbleInstall 'downloaded archive has an unexpected size' } if ($checksumsLength -le 0 -or $checksumsLength -gt 4194304) { Stop-BubbleInstall 'downloaded checksum manifest has an unexpected size' } $checksumMatches = @() $escapedArchiveName = [System.Text.RegularExpressions.Regex]::Escape($archiveName) foreach ($line in Get-Content -LiteralPath $checksumsPath) { $match = [System.Text.RegularExpressions.Regex]::Match( $line, "^([A-Fa-f0-9]{64})\s+\*?$escapedArchiveName$" ) if ($match.Success) { $checksumMatches += $match.Groups[1].Value } } if ($checksumMatches.Count -ne 1) { Stop-BubbleInstall 'the release manifest must contain exactly one valid checksum for this archive' } $expectedHash = $checksumMatches[0].ToLowerInvariant() $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { Stop-BubbleInstall 'SHA-256 verification failed; nothing was installed' } Write-Host 'Release checksum verified.' Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) try { $expectedPrefix = "$expectedBundleName/" $seenEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) [long]$expandedBytes = 0 $entryCount = 0 foreach ($entry in $zip.Entries) { $entryCount += 1 if ($entryCount -gt 20000 -or $entry.Length -gt 1073741824 -or $expandedBytes -gt (1073741824 - $entry.Length)) { Stop-BubbleInstall 'verified archive expands beyond the installer safety limit' } $expandedBytes += $entry.Length $entryName = $entry.FullName.Replace('\', '/') if ([string]::IsNullOrWhiteSpace($entryName) -or -not $entryName.StartsWith($expectedPrefix, [System.StringComparison]::Ordinal) -or $entryName.StartsWith('/', [System.StringComparison]::Ordinal) -or $entryName.Contains('//') -or $entryName.Contains(':') -or -not $seenEntries.Add($entryName)) { Stop-BubbleInstall 'verified archive contains an unexpected path' } foreach ($segment in $entryName.Split('/')) { if ($segment -in @('.', '..') -or $segment.EndsWith(' ') -or $segment.EndsWith('.')) { Stop-BubbleInstall 'verified archive contains an unsafe or ambiguous path' } } } } finally { $zip.Dispose() } Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDirectory $requiredFiles = @( (Join-Path $expectedBundleDirectory 'runtime/node.exe'), (Join-Path $expectedBundleDirectory 'bin/bubble-installed.js'), (Join-Path $expectedBundleDirectory 'package.json'), (Join-Path $expectedBundleDirectory 'NODE-LICENSE') ) foreach ($requiredFile in $requiredFiles) { if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { Stop-BubbleInstall 'verified archive has an unexpected bundle layout' } } $nodeLicensePath = Join-Path $expectedBundleDirectory 'NODE-LICENSE' if ((Get-Item -LiteralPath $nodeLicensePath).Length -le 0) { Stop-BubbleInstall 'verified archive contains an empty NODE-LICENSE' } $runtimePath = Join-Path $expectedBundleDirectory 'runtime/node.exe' $installedEntryPoint = Join-Path $expectedBundleDirectory 'bin/bubble-installed.js' & $runtimePath --version | Out-Null if ($LASTEXITCODE -ne 0) { Stop-BubbleInstall 'the bundled Node.js runtime could not run on this computer' } & $runtimePath --check $installedEntryPoint | Out-Null if ($LASTEXITCODE -ne 0) { Stop-BubbleInstall 'the installed command entry point did not pass its syntax check' } try { $package = Get-Content -LiteralPath (Join-Path $expectedBundleDirectory 'package.json') -Raw | ConvertFrom-Json } catch { Stop-BubbleInstall 'verified archive contains an invalid package.json' } $expectedPackageVersion = $Version.Substring(1) if ($package.name -ne 'bubble-terminal' -or $package.version -ne $expectedPackageVersion) { Stop-BubbleInstall 'verified archive identity does not match the requested Bubble Terminal release' } $profile = [ordered]@{ format = 'bubble-terminal-install' version = 1 server = $normalizedServer } $profileJson = ($profile | ConvertTo-Json -Compress) + [System.Environment]::NewLine [System.IO.File]::WriteAllText( (Join-Path $expectedBundleDirectory 'bubble-terminal-install.json'), $profileJson, $utf8NoBom ) $null = New-Item -ItemType Directory -Path $resolvedInstallRoot -Force Assert-NotReparsePoint -Path $resolvedInstallRoot $null = New-Item -ItemType Directory -Path $versionsDirectory -Force $null = New-Item -ItemType Directory -Path $binDirectory -Force Assert-NotReparsePoint -Path $versionsDirectory Assert-NotReparsePoint -Path $binDirectory $transactionId = [System.Guid]::NewGuid().ToString('N') $stagedVersionDirectory = Join-Path $versionsDirectory (".$Version.new-$transactionId") $backupVersionDirectory = Join-Path $versionsDirectory (".$Version.old-$transactionId") Assert-DirectChildPath -Parent $versionsDirectory -Child $stagedVersionDirectory Assert-DirectChildPath -Parent $versionsDirectory -Child $backupVersionDirectory Move-Item -LiteralPath $expectedBundleDirectory -Destination $stagedVersionDirectory $hadPreviousVersion = Test-Path -LiteralPath $versionDirectory if ($hadPreviousVersion) { if (-not (Test-Path -LiteralPath $versionDirectory -PathType Container)) { Stop-BubbleInstall "cannot replace non-directory version path: $versionDirectory" } Assert-NotReparsePoint -Path $versionDirectory $previousRequiredFiles = @( (Join-Path $versionDirectory 'runtime/node.exe'), (Join-Path $versionDirectory 'bin/bubble-installed.js'), (Join-Path $versionDirectory 'bubble-terminal-install.json') ) foreach ($previousRequiredFile in $previousRequiredFiles) { if (-not (Test-Path -LiteralPath $previousRequiredFile -PathType Leaf)) { Stop-BubbleInstall "refusing to replace an unrecognized directory at $versionDirectory" } } try { $previousProfile = Get-Content -LiteralPath ` (Join-Path $versionDirectory 'bubble-terminal-install.json') -Raw | ConvertFrom-Json } catch { Stop-BubbleInstall "refusing to replace an unrecognized directory at $versionDirectory" } $previousProfileProperties = @($previousProfile.PSObject.Properties.Name) if ($previousProfileProperties -notcontains 'format' -or $previousProfileProperties -notcontains 'version' -or $previousProfile.format -ne 'bubble-terminal-install' -or $previousProfile.version -ne 1) { Stop-BubbleInstall "refusing to replace an unrecognized directory at $versionDirectory" } Move-Item -LiteralPath $versionDirectory -Destination $backupVersionDirectory } try { Move-Item -LiteralPath $stagedVersionDirectory -Destination $versionDirectory } catch { if ($hadPreviousVersion -and -not (Test-Path -LiteralPath $versionDirectory) -and (Test-Path -LiteralPath $backupVersionDirectory -PathType Container)) { Move-Item -LiteralPath $backupVersionDirectory -Destination $versionDirectory } throw } $activatedVersion = $true $shim = @( '@REM Bubble Terminal managed launcher', '@echo off', 'setlocal', "`"%~dp0..\versions\$Version\runtime\node.exe`" `"%~dp0..\versions\$Version\bin\bubble-installed.js`" %*", 'exit /b %ERRORLEVEL%', '' ) -join "`r`n" Write-AtomicTextFile -Path $shimPath -Contents $shim -Encoding $ascii if (-not $NoPathUpdate) { $pathWasAdded = Add-BubbleToUserPath -BinDirectory $binDirectory $bubbleResolutionAfterInstall = Get-BubbleCommandResolution $bubbleCommandReady = Test-BubbleCommandResolution ` -Resolution $bubbleResolutionAfterInstall ` -ExpectedPath $shimPath } if (Test-Path -LiteralPath $backupVersionDirectory) { try { Remove-ScopedDirectory -Parent $versionsDirectory -Path $backupVersionDirectory $backupVersionDirectory = $null } catch { Write-Warning "The previous copy was preserved at $backupVersionDirectory because it could not be removed." } } $installSucceeded = $true } catch { $installError = $_ if ($activatedVersion -and (Test-Path -LiteralPath $versionDirectory)) { try { Remove-ScopedDirectory -Parent $versionsDirectory -Path $versionDirectory if ($null -ne $backupVersionDirectory -and (Test-Path -LiteralPath $backupVersionDirectory -PathType Container)) { Move-Item -LiteralPath $backupVersionDirectory -Destination $versionDirectory $backupVersionDirectory = $null } } catch { Write-Warning "Automatic rollback could not finish; inspect $versionsDirectory before retrying." } } if ($hadPreviousShim -and $null -ne $previousShimContents) { try { Write-AtomicTextFile -Path $shimPath -Contents $previousShimContents -Encoding $ascii } catch { Write-Warning "The previous launcher could not be restored at $shimPath." } } elseif (Test-Path -LiteralPath $shimPath -PathType Leaf) { try { $failedShim = [System.IO.File]::ReadAllText($shimPath) if ($failedShim.Contains('@REM Bubble Terminal managed launcher')) { Remove-Item -LiteralPath $shimPath -Force } } catch { Write-Warning "The incomplete launcher could not be removed from $shimPath." } } throw $installError } finally { [System.Net.ServicePointManager]::SecurityProtocol = $priorSecurityProtocol if ($null -ne $installLockStream) { $installLockStream.Dispose() } if ($null -ne $stagedVersionDirectory -and (Test-Path -LiteralPath $stagedVersionDirectory)) { try { Remove-ScopedDirectory -Parent $versionsDirectory -Path $stagedVersionDirectory } catch { Write-Warning "An incomplete staged version could not be removed from $stagedVersionDirectory." } } if ($null -ne $backupVersionDirectory -and (Test-Path -LiteralPath $backupVersionDirectory) -and -not (Test-Path -LiteralPath $versionDirectory)) { try { Assert-DirectChildPath -Parent $versionsDirectory -Child $backupVersionDirectory Assert-NotReparsePoint -Path $backupVersionDirectory Move-Item -LiteralPath $backupVersionDirectory -Destination $versionDirectory } catch { Write-Warning "The previous version remains recoverable at $backupVersionDirectory." } } if (Test-Path -LiteralPath $workDirectory) { try { Remove-ScopedDirectory -Parent $temporaryParent -Path $workDirectory } catch { Write-Warning "Temporary installer files could not be removed from $workDirectory." } } } if (-not $installSucceeded) { Stop-BubbleInstall 'installation did not complete' } Write-Host '' Write-Host "Bubble Terminal $Version is installed for this user." Write-Host "Install location: $versionDirectory" $nextCommand = 'bubble' if ($NoPathUpdate) { $nextCommand = "`"$(Join-Path $binDirectory 'bubble.cmd')`"" Write-Host "Run it with: $nextCommand" } elseif ($bubbleCommandReady) { if ($pathWasAdded) { Write-Host 'The bubble command was added to your user PATH.' } Write-Host 'Type bubble to open your saved conversation.' } else { if ($null -ne $bubbleResolutionAfterInstall) { Write-Warning "The short command resolves to $($bubbleResolutionAfterInstall.Description), not the managed Bubble Terminal launcher." } $nextCommand = "`"$shimPath`"" Write-Host "Run it with: $nextCommand" } Write-Host 'Bubble opens one saved chat and uses your browser for secure account approval.' Write-Host '' Write-Host 'Update later by rerunning the reviewed official installer.' if (-not $NoLaunch -and -not $customInstallRoot) { Write-Host '' Write-Host 'Opening Bubble now...' & $shimPath if ($LASTEXITCODE -ne 0) { Write-Warning 'Bubble closed before sign-in finished; run bubble to try again.' } }