# AdaL CLI Installer for Windows (PowerShell) # Usage: # irm https://adal.sylph.ai/install/windows | iex # latest stable # $v="beta"; irm https://adal.sylph.ai/install/windows | iex # latest beta # $v="1.0.2-beta.1"; irm https://adal.sylph.ai/install/windows | iex # specific version # # Installs to ~/.adal/versions// with adal.cmd in ~/.adal/bin/ # Support both: direct script execution (param) and irm | iex ($v variable) param( [string]$Version = "", [switch]$NoModifyPath ) # When piped via irm | iex, param() is ignored — pick up $v from caller scope # Use Test-Path variable: to avoid throwing if caller has Set-StrictMode active if (-not $Version -and (Test-Path variable:v)) { $Version = $v -replace '^v', '' } if (-not $NoModifyPath -and (Test-Path variable:NoPath) -and $NoPath) { $NoModifyPath = $true } Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = 'SilentlyContinue' # ─── Constants ──────────────────────────────────────────────────────────────── $RELEASES_URL = "https://d35qg8ac0yw4p7.cloudfront.net/cli" $TRACK_URL = if ($env:ADAL_TRACK_URL) { $env:ADAL_TRACK_URL } else { "https://adal.sylph.ai/api/installs/track" } $INSTALL_BASE = "$env:USERPROFILE\.adal" $VERSIONS_DIR = "$INSTALL_BASE\versions" $BIN_DIR = "$INSTALL_BASE\bin" # ─── Network Retry ─────────────────────────────────────────────────────────── # # Issue #3246. From some home networks a NEW TLS connection to CloudFront over # IPv6 is reset during the handshake. PowerShell reports it as "The underlying # connection was closed: An unexpected error occurred on a send." Measured on a # Windows 11 machine on 2026-10-14: 50-85% of new connections to CloudFront's # IPv6 addresses were reset, 0 of 48 over IPv4, and 0 to Google, Cloudflare or # Microsoft over IPv6. curl.exe and Node failed the same way, so this is not a # PowerShell 5.1 or TLS-version problem, and forcing TLS 1.2 does not help. # # Every attempt below opens a new connection, so a retry does what the user had # to do by re-running the command. Only failures where no HTTP status came back # are retried, plus 5xx: a 404 means the version really does not exist. $NETWORK_ATTEMPTS = 5 $NETWORK_RETRY_BASE_MS = 300 function Test-RetryableNetworkError { param($ErrorRecord) for ($e = $ErrorRecord.Exception; $e; $e = $e.InnerException) { # WebException (5.1) and HttpResponseException (7) carry the response # when the server answered. No response means the connection failed. if ($e.PSObject.Properties['Response'] -and $e.Response) { return ([int]$e.Response.StatusCode -ge 500) } } return $true } function Invoke-WithRetry { param([scriptblock]$Action) for ($attempt = 1; ; $attempt++) { try { return (& $Action) } catch { if ($attempt -ge $NETWORK_ATTEMPTS -or -not (Test-RetryableNetworkError $_)) { throw } Start-Sleep -Milliseconds ($NETWORK_RETRY_BASE_MS * $attempt) } } } # ─── Platform Detection ────────────────────────────────────────────────────── if (-not [Environment]::Is64BitProcess) { Write-Error "AdaL CLI does not support 32-bit Windows." exit 1 } if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { Write-Error "AdaL CLI does not yet support Windows ARM64." exit 1 } $Platform = "win32-x64" # ─── Version Resolution ────────────────────────────────────────────────────── function Resolve-Version { if ($Version) { $script:Version = $Version -replace '^v', '' # Support channel names (e.g., "beta") — resolve to actual version number if ($Version -match '^[a-z]+$') { try { $script:Version = (Invoke-WithRetry { Invoke-RestMethod -Uri "$RELEASES_URL/$Version" -ErrorAction Stop }).Trim() } catch { Write-Error "Channel '$Version' not found." exit 1 } } else { try { $null = Invoke-WithRetry { Invoke-RestMethod -Uri "$RELEASES_URL/manifests/manifest-$Version.json" -ErrorAction Stop } } catch { Write-Error "Version $Version not found." exit 1 } } } else { try { $script:Version = (Invoke-WithRetry { Invoke-RestMethod -Uri "$RELEASES_URL/latest" -ErrorAction Stop }).Trim() } catch { Write-Error "Failed to determine latest version." exit 1 } } } # ─── Check Existing ────────────────────────────────────────────────────────── function Test-ExistingInstall { $versionDir = "$VERSIONS_DIR\$Version" if (Test-Path $versionDir) { Write-Host "Version $Version is already installed." -ForegroundColor DarkGray Setup-Symlinks Cleanup-OldVersions Setup-Path Setup-GitHubActions Print-Success exit 0 } } # ─── Progress output ────────────────────────────────────────────────────────── # install.sh shows a live percentage during the download and Windows showed # nothing at all (#2770): $ProgressPreference is silenced at the top of this # script and Invoke-WebRequest was called bare, so a multi-hundred-MB download # and a slow Expand-Archive both looked identical to a hang. # # Both helpers below write a single line in place, and fall back to one static # line when output is redirected so a captured install log doesn't fill with # carriage-return frames. Every final status is at least as long as the frame it # overwrites, so the carriage return never leaves a tail behind. function Write-InstallProgress { param([int]$Percent, [switch]$Final) $line = "Installing adal v${Version}... ($Percent%)" if ([Console]::IsOutputRedirected) { if ($Final -or $Percent -eq 0) { Write-Host $line -ForegroundColor DarkGray } return } if ($Final) { Write-Host "`r$line" -ForegroundColor DarkGray } else { Write-Host "`r$line" -ForegroundColor DarkGray -NoNewline } } function Write-ExtractProgress { param([int]$Percent, [switch]$Final) $line = "Extracting... ($Percent%)" if ([Console]::IsOutputRedirected) { if ($Final -or $Percent -eq 0) { Write-Host $line -ForegroundColor DarkGray } return } if ($Final) { Write-Host "`r$line" -ForegroundColor DarkGray } else { Write-Host "`r$line" -ForegroundColor DarkGray -NoNewline } } # Streams the download so bytes can be counted as they arrive, rather than # handing the whole thing to Invoke-WebRequest and going silent until it # returns. Content-Length is authoritative when the server sends it; the # manifest's recorded size is the fallback, which is also what install.sh uses. # # Returns $true on success. On any failure it returns $false rather than # throwing, so the caller can fall back to the plain Invoke-WebRequest path and # keep the original error handling — a progress bar must never be the reason an # install fails. function Invoke-DownloadWithProgress { param([string]$Uri, [string]$OutFile, [long]$ExpectedSize = 0) $client = $null $response = $null $inStream = $null $outStream = $null # Only an exception (connection reset, timeout, broken stream) is worth a # retry; an HTTP error or a missing size will fail the same way again. $script:DownloadFailureRetryable = $false try { Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue $client = New-Object System.Net.Http.HttpClient $client.Timeout = [TimeSpan]::FromMinutes(30) # ResponseHeadersRead: return as soon as the headers land, so the body # can be read incrementally instead of being buffered in full first. $response = $client.GetAsync( $Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead ).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { return $false } $total = $ExpectedSize $declared = $response.Content.Headers.ContentLength if ($declared) { $total = [long]$declared } if ($total -le 0) { return $false } $inStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $outStream = [System.IO.File]::Create($OutFile) $buffer = New-Object byte[] 131072 $received = [long]0 $lastPct = 0 Write-InstallProgress 0 while ($true) { $read = $inStream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $outStream.Write($buffer, 0, $read) $received += $read $pct = [int](($received * 100) / $total) if ($pct -gt 100) { $pct = 100 } if ($pct -ne $lastPct) { $lastPct = $pct Write-InstallProgress $pct } } $outStream.Dispose() $outStream = $null # A truncated body still produces a file; the checksum check downstream # is what catches it, but there is no point claiming 100% either. if ($received -lt $total) { return $false } Write-InstallProgress 100 -Final return $true } catch { $script:DownloadFailureRetryable = $true return $false } finally { if ($outStream) { $outStream.Dispose() } if ($inStream) { $inStream.Dispose() } if ($response) { $response.Dispose() } if ($client) { $client.Dispose() } } } # Expand-Archive with $ProgressPreference silenced is completely silent, and on # Windows it is slow -- many small files -- so #2770 reports the unpack looking # stuck. Extracting entry by entry instead gives a real percentage rather than a # guess, and costs nothing: it is the same work Expand-Archive does. # # Deliberately NOT a background job. Windows PowerShell 5.1's job # infrastructure needs an intact user profile and fails with "The Persistence # Path does not exist" without one -- caught by the installer tests running on # 5.1, and a class of failure an installer should not be exposed to at all. # # Falls back to plain Expand-Archive if the compression assembly cannot be # loaded, so the install still works where this path is unavailable. function Expand-ArchiveWithProgress { param([string]$Path, [string]$Destination) try { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop } catch { Expand-Archive -Path $Path -DestinationPath $Destination -Force return } $archive = $null try { $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) New-Item -ItemType Directory -Force -Path $Destination | Out-Null $root = [System.IO.Path]::GetFullPath($Destination) if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $root += [System.IO.Path]::DirectorySeparatorChar } $total = $archive.Entries.Count if ($total -le 0) { return } $done = 0 $lastPct = 0 Write-ExtractProgress 0 foreach ($entry in $archive.Entries) { # NTFS reads a colon as an Alternate Data Stream selector, so # "file.txt:hidden" writes into a hidden stream attached to # file.txt instead of creating a file. That stays inside # $Destination, so the containment check below passes it happily. # Expand-Archive -- which this function replaced -- rejects such # names outright, and a relative zip entry never legitimately # contains a colon, so match that behaviour rather than inheriting # a gap by hand-rolling the walk. if ($entry.FullName -match ':') { throw "Archive entry has an invalid name: $($entry.FullName)" } $target = [System.IO.Path]::GetFullPath((Join-Path $Destination $entry.FullName)) # Zip-slip guard. This archive is ours and its checksum was verified # above, but an extractor that can be talked into writing outside # its destination is not something to leave to trust. if (-not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Archive entry would extract outside the destination: $($entry.FullName)" } if ([string]::IsNullOrEmpty($entry.Name)) { # Directory entry. New-Item -ItemType Directory -Force -Path $target | Out-Null } else { $parent = [System.IO.Path]::GetDirectoryName($target) if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) } $done++ $pct = [int](($done * 100) / $total) if ($pct -ne $lastPct) { $lastPct = $pct Write-ExtractProgress $pct } } Write-ExtractProgress 100 -Final } finally { if ($archive) { $archive.Dispose() } } } # ─── Install ────────────────────────────────────────────────────────────────── function Install-Version { Write-Host "" $filename = "adal-${Version}-${Platform}.zip" $downloadUrl = "$RELEASES_URL/$Version/$filename" $manifestUrl = "$RELEASES_URL/manifests/manifest-${Version}.json" # Fetch manifest for checksum and recorded size. The size is the fallback # for the progress percentage when the CDN does not send Content-Length — # same field install.sh's download_with_progress uses. $expectedChecksum = "" $expectedSize = [long]0 try { # Retried: a reset here used to be swallowed, which silently skipped # the checksum check below. $manifest = Invoke-WithRetry { Invoke-RestMethod -Uri $manifestUrl -ErrorAction Stop } $platformInfo = $manifest.platforms.$Platform if ($platformInfo) { $expectedChecksum = $platformInfo.checksum if ($platformInfo.PSObject.Properties.Name -contains 'size') { $expectedSize = [long]$platformInfo.size } } } catch {} # Download zip $tmpDir = New-Item -ItemType Directory -Force -Path "$env:TEMP\adal-install-$(Get-Random)" $zipPath = "$tmpDir\$filename" # A connection reset (#3246) makes the streaming download return $false # with $script:DownloadFailureRetryable set, so try it again on a new # connection. Other failures (HTTP error, no size known) go straight to the # fallback, as before. $downloaded = $false for ($attempt = 1; $attempt -le $NETWORK_ATTEMPTS; $attempt++) { if (Invoke-DownloadWithProgress -Uri $downloadUrl -OutFile $zipPath -ExpectedSize $expectedSize) { $downloaded = $true break } if (-not $script:DownloadFailureRetryable) { break } Start-Sleep -Milliseconds ($NETWORK_RETRY_BASE_MS * $attempt) } if (-not $downloaded) { # Streaming download failed, or there was no size to base a percentage # on. Fall back to the original call with the original static line, so # the install still works — and still fails the same way — when the # streaming path is unavailable. Write-Host "Installing adal v${Version}..." -ForegroundColor DarkGray try { Invoke-WithRetry { Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -ErrorAction Stop } } catch { Write-Error "Download failed: $_" Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue exit 1 } } # Verify checksum if ($expectedChecksum) { $actualChecksum = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLower() if ($actualChecksum -ne $expectedChecksum) { Write-Error "Checksum verification failed." Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue exit 1 } } # Extract $extractDir = "$tmpDir\extracted" Expand-ArchiveWithProgress -Path $zipPath -Destination $extractDir # Move to versions directory New-Item -ItemType Directory -Force -Path $VERSIONS_DIR | Out-Null $versionDir = "$VERSIONS_DIR\$Version" # Find the extracted adal-{version} directory $extractedSubDir = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 if ($extractedSubDir) { if (Test-Path $versionDir) { Remove-Item -Recurse -Force $versionDir } Move-Item -Path $extractedSubDir.FullName -Destination $versionDir -Force } else { if (Test-Path $versionDir) { Remove-Item -Recurse -Force $versionDir } Move-Item -Path $extractDir -Destination $versionDir -Force } # Cleanup Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue # Track after successful extraction — guarantees the row represents a # finished install/upgrade, not a failed/aborted attempt. Track-Install } # ─── Symlinks ───────────────────────────────────────────────────────────────── # The scheduler runs bun from ~/.adal/runtime/, a path that does not change # between versions. Windows Controlled folder access (like macOS folder access) # allows a program by its path, so a versioned path had to be allowed again # after every update. Same logic as install_stable_runtime in install.sh and # installStableRuntime() in shared_frontend/adapters/node/scheduler/ # stableRuntime.ts. A running bun.exe cannot be replaced or deleted, but it can # be renamed, so it is moved aside and deleted by a later install. Never fails # the install: the scheduler then runs the version's own bun.exe. function Install-StableRuntime { $src = "$VERSIONS_DIR\$Version\runtime\bun.exe" $dir = "$INSTALL_BASE\runtime" $target = "$dir\bun.exe" $tmp = "$target.$PID.tmp" if (-not (Test-Path -LiteralPath $src)) { return } try { New-Item -ItemType Directory -Force -Path $dir | Out-Null Get-ChildItem -LiteralPath $dir -Filter 'bun.exe.old-*' -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } Copy-Item -LiteralPath $src -Destination $tmp -Force if (Test-Path -LiteralPath $target) { try { Remove-Item -LiteralPath $target -Force -ErrorAction Stop } catch { $aside = "$target.old-$PID-$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())" Move-Item -LiteralPath $target -Destination $aside -ErrorAction Stop } } Move-Item -LiteralPath $tmp -Destination $target -ErrorAction Stop } catch { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue Write-Host "Could not update $target; scheduled tasks may need folder access allowed again after updates" -ForegroundColor DarkGray } } function Setup-Symlinks { New-Item -ItemType Directory -Force -Path $BIN_DIR | Out-Null # Before switching 'current': a scheduler restarting on the new version # must find the new bun.exe. Install-StableRuntime # Update 'current' junction $currentLink = "$VERSIONS_DIR\current" if (Test-Path $currentLink) { cmd /c "rmdir `"$currentLink`"" 2>$null Remove-Item -Force $currentLink -ErrorAction SilentlyContinue } cmd /c "mklink /J `"$currentLink`" `"$VERSIONS_DIR\$Version`"" 2>$null | Out-Null # Create adal.cmd wrapper in bin directory $wrapperPath = "$BIN_DIR\adal.cmd" $wrapperContent = @" @echo off setlocal set "SCRIPT_DIR=%~dp0" "%SCRIPT_DIR%..\versions\current\runtime\bun.exe" "%SCRIPT_DIR%..\versions\current\adal-cli.js" %* "@ Set-Content -Path $wrapperPath -Value $wrapperContent -Encoding ASCII } # ─── PATH Setup ─────────────────────────────────────────────────────────────── function Setup-Path { if ($NoModifyPath) { return } # `irm | iex` runs in the user's own PowerShell session, so putting the # bin dir on this session's Path is what makes `adal` work right away. # Done on its own, not only when the registry lacks it: on a reinstall the # registry already has it, but a session opened before the first install # does not. if (($env:Path -split ';') -notcontains $BIN_DIR) { $env:Path = "$BIN_DIR;$env:Path" } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -notlike "*$BIN_DIR*") { # Catch and WARN -- not catch and swallow, and not abort. # # This is a registry write to HKCU\Environment, which a managed or # Group-Policy machine can refuse. Aborting is wrong: the install has # already succeeded by this point. But swallowing is worse than the # loud failure it would replace -- the user would get a cheerful # "installed!" and then `adal` not found in a new shell, with nothing # pointing at why. So say exactly what did not happen, and what to do. try { [Environment]::SetEnvironmentVariable("Path", "$BIN_DIR;$userPath", "User") } catch { Write-Host "" Write-Host "Could not add AdaL to your PATH automatically." -ForegroundColor Yellow Write-Host "Add this directory to your PATH by hand: $BIN_DIR" -ForegroundColor Yellow } } } # ─── Cleanup Old Versions ──────────────────────────────────────────────────── function Cleanup-OldVersions { $keep = 1 if (-not (Test-Path $VERSIONS_DIR)) { return } # TryParse rather than a [Version] cast. This directory is user-writable, # the -match filter is not anchored at the end, and [Version] accepts at # most four components -- so a leftover like "1.2.3.4.5-partial" passes the # filter and then throws inside Sort-Object. Under the script's # $ErrorActionPreference = "Stop" that abandons PATH setup, the warm-up and # the success banner for an install that has ALREADY succeeded. Anything # unparseable is simply not a cleanup candidate; the filter itself is # unchanged, so which directories qualify is unchanged too. $versions = @( Get-ChildItem -Path $VERSIONS_DIR -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "current" -and $_.Name -match '^\d+\.\d+\.\d+' } | ForEach-Object { $parsed = $null if ([Version]::TryParse(($_.Name -replace '-.*$', ''), [ref]$parsed)) { [PSCustomObject]@{ Dir = $_; Version = $parsed } } } | Sort-Object -Property Version -Descending ) if ($versions.Count -gt $keep) { foreach ($item in ($versions | Select-Object -Skip $keep)) { Remove-Item -Recurse -Force $item.Dir.FullName -ErrorAction SilentlyContinue } } } # ─── GitHub Actions ─────────────────────────────────────────────────────────── function Setup-GitHubActions { if ($env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $BIN_DIR } } # ─── Backend warm-up ────────────────────────────────────────────────────────── # Launch the just-installed CLI once, for real, so Windows Defender scans the # freshly extracted files here — during the install the user is already waiting # through — instead of on their first launch. # # Same mechanism and same reasoning as warm_backend() in install.sh; read that # comment for the full root-cause investigation. The short version: the scanner # scans an executable the first time a given copy of it runs and caches the # verdict against that copy, so a fresh install always pays a full scan on the # user's first `adal`. On Windows the scanner is Defender real-time protection. # # Windows is the platform where this is most firmly established. It is the one # where CI could reproduce the cost end to end — 8,003ms -> 2,700ms (3.0x), with # MsMpEng burning 2.45 CPU-seconds — while hosted macOS runners ship XProtect # launch scans disabled and Linux has no on-execute scanner at all. # # This BLOCKS, behind a "Preparing AdaL..." line, and that is the point. A # warm-up that runs in the background only helps a user who does not start adal # straight away: the scanner commits its verdict when the scan COMPLETES, so a # launch that lands inside the warm-up window races an in-flight scan rather # than finding a cached one — and Print-Success below ends with "Next: Run # adal", which invites exactly that launch. # # Never fails the install: every failure mode here is caught and costs the user # nothing but the old behaviour. # # Opt-out: $env:ADAL_NO_WARM = '1' (mirrors ADAL_NO_TRACK for the tracker). # Hard cap on the visible wait. Only reached when the warm-up cannot finish at # all — a backend that never comes up, so the CLI never signals. A healthy cold # start measured ~11s; 30s keeps the cap well clear of a real scan, because a # scan killed midway caches nothing and the user would pay it again. $WARM_TIMEOUT_S = 30 # Does the version we just installed know how to say when its warm-up is done? # # Given ADAL_WARMUP_READY_FILE=, the warmed CLI writes that file once it # is fully started — backend spawned and answering, renderer mounted. The # variable is read only by versions that honour it, so its presence as a literal # in the shipped bundle is an exact capability probe with no version table to # keep up to date. Installing something older (-Version 1.0.0, or any release # predating the signal) skips the warm-up rather than making the user watch a # 30s wait for something that can never arrive. # # A file and not a line of output, because nothing the CLI prints at that point # is readable from here: by the time startup completes, OpenTUI owns stdout and # stderr, and a write there does not survive to a caller's log. function Test-WarmReadySupport { param([string]$VersionDir) $candidates = @("$VersionDir\adal-cli.js") try { # -Recurse for parity with install.sh's `grep -r` over the same # directory. Today's bundler output is flat, so without it the two # probes would only diverge if chunks/ ever nested — and this one # fails safe (skips the warm-up forever) rather than loudly. $chunks = Get-ChildItem -Path "$VersionDir\chunks" -Filter '*.js' -File -Recurse -ErrorAction SilentlyContinue if ($chunks) { $candidates += ($chunks | ForEach-Object { $_.FullName }) } } catch { } foreach ($file in $candidates) { try { if (-not (Test-Path -LiteralPath $file)) { continue } if (Select-String -LiteralPath $file -Pattern 'ADAL_WARMUP_READY_FILE' ` -SimpleMatch -Quiet -ErrorAction SilentlyContinue) { return $true } } catch { } } return $false } # Single progress line. Static single line when output is redirected, so a # captured install log (CI, `... | Tee-Object`) doesn't fill with carriage # returns. Every final status is longer than the "(NNs)" tick it overwrites, so # the carriage return never leaves a tail of the previous frame behind. function Write-WarmProgress { param([string]$Suffix = "", [switch]$Final) $line = "Preparing AdaL..." if ($Suffix) { $line = "$line $Suffix" } if ([Console]::IsOutputRedirected) { # Ticks are noise in a log; only the opening line and the outcome. if ($Final -or -not $Suffix) { Write-Host $line -ForegroundColor DarkGray } return } if ($Final) { Write-Host "`r$line" -ForegroundColor DarkGray } else { Write-Host "`r$line" -ForegroundColor DarkGray -NoNewline } } # /T because the CLI transitively spawns the backend, and signalling the pid # alone orphans it — reparented, still holding a version directory. Same # technique killPreWarmTree() and resourceRelease.ts use on Windows. function Stop-WarmProcess { param($Proc) if ($null -eq $Proc) { return } try { if ($Proc.HasExited) { return } } catch { return } try { & taskkill.exe /F /T /PID $($Proc.Id) *> $null } catch { } try { $Proc.WaitForExit(5000) | Out-Null } catch { } } # One line per install, so this only grows on a machine that reinstalls a lot -- # but nothing else truncates it. Keep the most recent 200 lines. # # cold_start_ms is wall time from launch, so it includes the scan itself. # hydration_ms is what the CLI measured internally, which starts only once its # own code is running — the gap between the two IS the scan. function Write-WarmTiming { param([bool]$Ready, [int]$ElapsedMs, [string]$HydrationMs, [bool]$TimedOut) try { $log = "$INSTALL_BASE\warmup-timing.log" New-Item -ItemType Directory -Force -Path $INSTALL_BASE -ErrorAction SilentlyContinue | Out-Null if (Test-Path -LiteralPath $log) { $lines = @(Get-Content -LiteralPath $log -ErrorAction SilentlyContinue) if ($lines.Count -gt 200) { Set-Content -LiteralPath $log -Value ($lines | Select-Object -Last 200) } } $devMode = if ($env:ADAL_DEV_MODE) { $env:ADAL_DEV_MODE } else { 'false' } if ($Ready) { $hydration = if ($HydrationMs) { $HydrationMs } else { 'unknown' } $entry = "cold_start_ms=$ElapsedMs hydration_ms=$hydration platform=win32 dev_mode=$devMode outcome=ready" } else { $outcome = if ($TimedOut) { 'timeout' } else { 'exited' } $entry = "cold_start_ms=unknown platform=win32 dev_mode=$devMode outcome=$outcome" } Add-Content -LiteralPath $log -Value $entry -ErrorAction SilentlyContinue } catch { } } function Warm-Backend { if ($env:ADAL_NO_WARM -eq '1') { return } $versionDir = "$VERSIONS_DIR\current" # Invoke bun.exe with adal-cli.js directly rather than going through # adal.cmd. It is byte for byte what the launcher does (see build-native.sh's # WRAPPER_WIN), but without a cmd.exe layer in between -- so $proc.Id is the # real process rather than the shell wrapping it, which is the exact footgun # buildPreWarmSpawnOptions had to work around on this platform. $bun = "$versionDir\runtime\bun.exe" $cliJs = "$versionDir\adal-cli.js" if (-not (Test-Path -LiteralPath $bun)) { return } if (-not (Test-Path -LiteralPath $cliJs)) { return } # An older release cannot signal, and waiting blind would just be 30s of # staring at a progress line. Skip rather than guess. if (-not (Test-WarmReadySupport $versionDir)) { return } $readyFile = Join-Path $env:TEMP "adal-warm-ready-$PID-$(Get-Random)" Remove-Item -LiteralPath $readyFile -Force -ErrorAction SilentlyContinue $prevSpawnedChild = $env:ADAL_IS_SPAWNED_CHILD $prevReadyFile = $env:ADAL_WARMUP_READY_FILE $proc = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # ADAL_IS_SPAWNED_CHILD=1 is REQUIRED, not optional: without it this # session runs the CLI's normal auto-update check, which can self-update # mid-warm-up and delete the version directory it is running from, # leaving a concurrent real launch with "This session is outdated". # # ADAL_WARMUP_READY_FILE is what makes the CLI touch that path once it # is fully started. It gates nothing else. [Environment]::SetEnvironmentVariable('ADAL_IS_SPAWNED_CHILD', '1', 'Process') [Environment]::SetEnvironmentVariable('ADAL_WARMUP_READY_FILE', $readyFile, 'Process') # Hidden window, not -NoNewWindow: the warm-up is a real interactive # session and would otherwise render its TUI over the installer's output. $proc = Start-Process -FilePath $bun -ArgumentList ('"' + $cliJs + '"') ` -PassThru -WindowStyle Hidden -ErrorAction Stop } catch { return } finally { [Environment]::SetEnvironmentVariable('ADAL_IS_SPAWNED_CHILD', $prevSpawnedChild, 'Process') [Environment]::SetEnvironmentVariable('ADAL_WARMUP_READY_FILE', $prevReadyFile, 'Process') } try { Write-WarmProgress $ready = $false $lastTick = -1 while ($sw.Elapsed.TotalSeconds -lt $WARM_TIMEOUT_S) { if (Test-Path -LiteralPath $readyFile) { $ready = $true; break } # Died -- crash or exec failure. Nothing left to wait for, and no # reason to hold the line for the rest of the window. if ($proc.HasExited) { break } Start-Sleep -Milliseconds 100 $secs = [int]$sw.Elapsed.TotalSeconds if ($secs -ne $lastTick) { $lastTick = $secs Write-WarmProgress "($($secs)s)" } } $sw.Stop() $hydrationMs = '' if ($ready) { try { $raw = Get-Content -LiteralPath $readyFile -Raw -ErrorAction SilentlyContinue if ($raw) { $hydrationMs = ($raw -replace '\D', '') } } catch { } Write-WarmProgress ("done in {0:N1}s" -f $sw.Elapsed.TotalSeconds) -Final } else { # Not confirmed. The install is fine either way, so say as little as # possible and put the detail in the timing log: the first launch # may still pay part of the scan. Write-WarmProgress "skipped" -Final } Write-WarmTiming -Ready $ready -ElapsedMs ([int]$sw.Elapsed.TotalMilliseconds) ` -HydrationMs $hydrationMs -TimedOut ($sw.Elapsed.TotalSeconds -ge $WARM_TIMEOUT_S) } catch { } finally { Stop-WarmProcess $proc Remove-Item -LiteralPath $readyFile -Force -ErrorAction SilentlyContinue } } function Print-Success { Write-Host "" Write-Host "AdaL CLI v${Version} installed!" -ForegroundColor Green Write-Host "" Write-Host "Location: $BIN_DIR\adal.cmd" -ForegroundColor DarkGray Write-Host "" Write-Host "Next: Run " -ForegroundColor DarkGray -NoNewline Write-Host "adal" -NoNewline Write-Host " to get started" -ForegroundColor DarkGray Write-Host "" # Started from Command Prompt (`powershell -Command "irm ... | iex"`), this # script is a child process, and a child cannot change the Path of the cmd # window that started it. The docs line appends the `set` below for that # reason; this covers anyone who typed the line without it. if (-not $NoModifyPath -and (Get-ParentProcessName) -eq 'cmd') { Write-Host "In Command Prompt, if this window cannot find adal, run:" -ForegroundColor Yellow Write-Host ' set "PATH=%USERPROFILE%\.adal\bin;%PATH%"' Write-Host "" } } function Get-ParentProcessName { try { $self = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction Stop return (Get-Process -Id $self.ParentProcessId -ErrorAction Stop).ProcessName } catch { return $null } } # ─── Anonymous install tracking ────────────────────────────────────────────── # Fire-and-forget POST so we can count actual installs/upgrades. # Only called from Install-Version, so same-version no-op reinstalls don't # fire (Test-ExistingInstall exits earlier on that path). # Honours $env:ADAL_NO_TRACK = '1' for opt-out. function Track-Install { if ($env:ADAL_NO_TRACK -eq '1') { return } if (-not $TRACK_URL) { return } # Two channels: "stable" = clean semver (X.Y.Z exactly), "beta" = anything else. # Any version with a `-suffix` (-beta, -alpha, -rc, -preview, -dev, etc.) is "beta" # so dev/internal builds can't masquerade as stable in the dashboard. $channel = if ($Version -match '^\d+\.\d+\.\d+$') { "stable" } else { "beta" } # install vs upgrade — does any prior version dir exist? $eventType = "install" if (Test-Path $VERSIONS_DIR) { $existing = Get-ChildItem $VERSIONS_DIR -Directory -ErrorAction SilentlyContinue | ` Where-Object { $_.Name -ne 'current' -and $_.Name -ne $Version } if ($existing) { $eventType = "upgrade" } } $body = @{ platform = "cli" channel = $channel event_type = $eventType version = $Version } | ConvertTo-Json -Compress # Background job; suppress all errors. -TimeoutSec keeps the script from hanging. Start-Job -ScriptBlock { param($url, $body) try { Invoke-RestMethod -Uri $url -Method Post -Body $body ` -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch { } } -ArgumentList $TRACK_URL, $body | Out-Null } # ─── Main ───────────────────────────────────────────────────────────────────── # Dot-sourcing this script with ADAL_INSTALLER_NO_RUN=1 defines the functions # above and stops here, so they can be exercised directly — which is the only # way this file gets tested at all. No CI job runs a branch's copy of it # otherwise: cold-start-mechanism.yml, e2e-test-cli-cross-platform-spawn.yml and # e2e-test-cli-auto-update.yml all `irm https://adal.sylph.ai/install/windows`, # i.e. the published script, so a green Windows run on a PR that edits this file # proves nothing about the edit. # # `return` at script scope stops execution here without exiting the host, so a # dot-source keeps everything defined above. No user sets this variable. if ($env:ADAL_INSTALLER_NO_RUN -eq '1') { return } Resolve-Version Test-ExistingInstall Install-Version Setup-Symlinks # Wrapped for the same reason as Warm-Backend below: deleting an old version # directory is not required for a working install, so nothing in it should be # able to abandon PATH setup, the warm-up and the success banner. The known # throw inside it is fixed at source (see Cleanup-OldVersions); this is here so # a future one cannot reintroduce the same failure shape. try { Cleanup-OldVersions } catch { } Setup-Path Setup-GitHubActions # Wrapped, not bare: the function catches its own failures, but a handful of # statements (notably `Join-Path $env:TEMP ...`) run before its first internal # try block, and this script is under $ErrorActionPreference = "Stop". A # terminating error there would abort an install that has already succeeded, # before Print-Success ever runs. The warm-up is an optimisation; it does not # get to fail the install. try { Warm-Backend } catch { } Print-Success