# ============================================================================= # Broodle CLI installer (Windows / PowerShell) # # irm https://broodle.host/broodle-cli.ps1 | iex # # Downloads a prebuilt, checksum-verified bundle from broodle.host and puts # `broodle-cli` on your PATH. No git, no npm install, no compile step. # # RULES FOR THIS FILE - please keep them: # # 1. ASCII ONLY. `irm | iex` hands PowerShell a string it decodes with the # console codepage; a UTF-8 file without a BOM comes back mangled, and a # mangled character inside a string or comment can break parsing. The sh # installer died in the field for the same family of reason. # 2. Works on Windows PowerShell 5.1 (the one that ships with Windows) as # well as PowerShell 7+. No ternaries, no `??`, no `-Depth` on # ConvertFrom-Json, nothing 7-only. # 3. TLS 1.2 is forced before the first request: 5.1 on older builds still # negotiates TLS 1.0 and simply fails against modern servers. # # Versions install side by side under %USERPROFILE%\.broodle-cli\versions and # the launcher is only rewritten after the new build passes a smoke test. # ============================================================================= [CmdletBinding()] param( [string]$Version = $env:BROODLE_CLI_VERSION, [string]$BaseUrl = $(if ($env:BROODLE_CLI_BASE) { $env:BROODLE_CLI_BASE } else { 'https://broodle.host/cli' }), # USERPROFILE / LOCALAPPDATA always exist on Windows, but fall back to HOME # so parameter binding can never throw (pwsh on Linux/macOS, odd profiles). [string]$InstallDir = $( if ($env:BROODLE_CLI_HOME) { $env:BROODLE_CLI_HOME } else { $base = $env:USERPROFILE; if (-not $base) { $base = $env:HOME }; if (-not $base) { $base = '.' } Join-Path $base '.broodle-cli' }), [string]$BinDir = $( if ($env:BROODLE_CLI_BIN) { $env:BROODLE_CLI_BIN } else { $lb = $env:LOCALAPPDATA if ($lb) { Join-Path $lb 'Programs\broodle-cli\bin' } else { $hb = $env:USERPROFILE; if (-not $hb) { $hb = $env:HOME }; if (-not $hb) { $hb = '.' } Join-Path $hb '.broodle-cli\bin' } }), [switch]$Force, [switch]$Uninstall, [switch]$Help ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # otherwise Invoke-WebRequest is glacial $NodeMajorMin = 22 $LauncherMark = 'broodle-cli-launcher' # Every marker this installer has ever written. An installer that does not # recognise its own older output refuses to proceed and tells the user their # file "was not created by this installer" - which is what happened on macOS # after the marker was renamed. Never remove an entry from this list. $LegacyMarks = @('broodle-studio-cli-launcher') $LegacyNames = @('broodle') function Test-OurLauncher([string]$Path) { if (-not (Test-Path $Path)) { return $false } $text = '' try { $text = Get-Content $Path -Raw -ErrorAction Stop } catch { return $false } if ($text -match [regex]::Escape($LauncherMark)) { return $true } foreach ($m in $LegacyMarks) { if ($text -match [regex]::Escape($m)) { return $true } } # An older launcher that lost its marker is still ours if it execs the CLI # entry point out of a Broodle install. if (($text -match 'cli-entry\.js') -and ($text -match 'broodle')) { return $true } return $false } $LauncherName = 'broodle-cli' $NodeVersion = 'none' # PS 5.1 on older Windows negotiates TLS 1.0 by default and fails outright. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { # PowerShell 7 manages this itself; nothing to do. } function Write-Ok ($m) { Write-Host "[ ok ] " -ForegroundColor Green -NoNewline; Write-Host $m } function Write-Info ($m) { Write-Host "[ .. ] " -ForegroundColor Blue -NoNewline; Write-Host $m } function Write-Warn ($m) { Write-Host "[warn] " -ForegroundColor Yellow -NoNewline; Write-Host $m } function Write-Dim ($m) { Write-Host " $m" -ForegroundColor DarkGray } function Fail ($m) { Write-Host "[fail] " -ForegroundColor Red -NoNewline Write-Host $m Write-Host '' Write-Host " Diagnostics: PowerShell $($PSVersionTable.PSVersion) | node $NodeVersion | $([Environment]::OSVersion.VersionString)" Write-Host ' Stuck? Send this whole output to https://broodle.host/contact' Write-Host '' exit 1 } if ($Help) { Write-Host @' Broodle CLI installer irm https://broodle.host/broodle-cli.ps1 | iex With options, download first: irm https://broodle.host/broodle-cli.ps1 -OutFile install.ps1 .\install.ps1 -Version 0.21.8 Options: -Version install a specific published build -InstallDir

install root -BinDir

directory for the launcher -Force reinstall even if already current -Uninstall remove the install and the launcher '@ exit 0 } # --- uninstall -------------------------------------------------------------- if ($Uninstall) { $removed = $false foreach ($n in @($LauncherName) + $LegacyNames) { $target = Join-Path $BinDir "$n.cmd" if (Test-OurLauncher $target) { Remove-Item $target -Force Write-Ok "Removed $target" $removed = $true } } if (Test-Path $InstallDir) { Remove-Item $InstallDir -Recurse -Force Write-Ok "Removed $InstallDir" $removed = $true } if (-not $removed) { Write-Warn "Nothing to remove (looked in $InstallDir)" } Write-Dim 'Your settings in %USERPROFILE%\.broodle were left untouched.' exit 0 } Write-Host '' Write-Host ' Broodle CLI' -ForegroundColor Blue -NoNewline Write-Host " installer (Windows, PowerShell $($PSVersionTable.PSVersion.Major))" Write-Host '' # --- prerequisites ---------------------------------------------------------- $nodeCmd = Get-Command node -ErrorAction SilentlyContinue if (-not $nodeCmd) { Write-Host '[fail] ' -ForegroundColor Red -NoNewline Write-Host "Node.js $NodeMajorMin+ is required and was not found on PATH." Write-Host '' Write-Host ' Install it with: winget install OpenJS.NodeJS.LTS' Write-Host ' or download from: https://nodejs.org/en/download' Write-Host '' exit 1 } try { $NodeVersion = (& node -v).Trim().TrimStart('v') } catch { Fail "Could not run 'node -v'. Node.js appears to be on PATH but is not executable." } $nodeMajor = 0 if (-not [int]::TryParse(($NodeVersion -split '\.')[0], [ref]$nodeMajor)) { Fail "Could not read a version number from 'node -v' (got '$NodeVersion')." } if ($nodeMajor -lt $NodeMajorMin) { Fail "Node.js $NodeMajorMin or newer is required, but this is $NodeVersion. See https://nodejs.org/en/download" } Write-Ok "Node.js $NodeVersion" $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("broodle-cli-" + [System.Guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Force -Path $tmp | Out-Null try { # --- resolve which build to install -------------------------------------- Write-Info 'Resolving the current build' $manifestUrl = "$BaseUrl/version.json" try { $manifest = Invoke-RestMethod -Uri $manifestUrl -UseBasicParsing -TimeoutSec 30 } catch { Fail "Could not reach $manifestUrl - check your connection, proxy or firewall. ($($_.Exception.Message))" } $build = $manifest if ($Version) { $build = @($manifest.versions) | Where-Object { $_.version -eq $Version } | Select-Object -First 1 if (-not $build) { $known = (@($manifest.versions) | ForEach-Object { $_.version }) -join ', ' Fail "version $Version is not published. Available: $known" } } if (-not $build.version -or -not $build.tarball) { Fail 'The manifest is missing a version or tarball.' } $resolved = $build.version if ($build.tarball -match '^https?://') { $tarballUrl = $build.tarball } elseif ($build.tarball.StartsWith('/')) { $tarballUrl = ($BaseUrl -replace '/cli$', '') + $build.tarball } else { $tarballUrl = "$BaseUrl/$($build.tarball)" } $versionDir = Join-Path $InstallDir "versions\$resolved" $entry = Join-Path $versionDir 'scripts\cli-entry.js' if ((Test-Path (Join-Path $versionDir 'dist\cli.js')) -and (-not $Force)) { Write-Ok "Broodle CLI $resolved is already installed" } else { Write-Info "Downloading Broodle CLI $resolved" $archive = Join-Path $tmp 'cli.tar.gz' try { Invoke-WebRequest -Uri $tarballUrl -OutFile $archive -UseBasicParsing -TimeoutSec 300 } catch { Fail "Download failed: $tarballUrl ($($_.Exception.Message))" } if (-not (Test-Path $archive) -or (Get-Item $archive).Length -eq 0) { Fail 'The downloaded bundle is empty.' } if ($build.sha256) { $actual = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLower() if ($actual -ne $build.sha256.ToLower()) { Fail "Checksum mismatch - refusing to install.`n expected $($build.sha256)`n actual $actual" } Write-Ok 'Checksum verified' } else { Write-Warn 'The manifest carries no checksum for this build.' } $unpack = Join-Path $tmp 'unpack' New-Item -ItemType Directory -Force -Path $unpack | Out-Null # tar.exe ships with Windows 10 1803+ / Server 2019+. Older boxes fall back # to Node, which is already a hard requirement - so extraction never # depends on which Windows build this is. $extracted = $false if (Get-Command tar -ErrorAction SilentlyContinue) { & tar -xzf $archive -C $unpack 2>$null if ($LASTEXITCODE -eq 0) { $extracted = $true } } if (-not $extracted) { Write-Info 'tar.exe unavailable or failed - unpacking with Node instead' $extractor = Join-Path $tmp 'extract.js' @' var fs = require("fs"), zlib = require("zlib"), path = require("path"); var buf = zlib.gunzipSync(fs.readFileSync(process.argv[2])); var out = process.argv[3], off = 0; function str(b, s, l) { var e = b.indexOf(0, s); if (e === -1 || e > s + l) e = s + l; return b.toString("utf8", s, e); } while (off + 512 <= buf.length) { var name = str(buf, off, 100); if (!name) { off += 512; continue; } var size = parseInt(str(buf, off + 124, 12).trim() || "0", 8); var type = buf.toString("utf8", off + 156, off + 157); var prefix = str(buf, off + 345, 155); if (prefix) name = prefix + "/" + name; var dest = path.join(out, name); if (dest.indexOf(path.resolve(out)) !== 0 && path.resolve(dest).indexOf(path.resolve(out)) !== 0) { throw new Error("refusing to extract outside the target directory: " + name); } off += 512; if (type === "5") { fs.mkdirSync(dest, { recursive: true }); } else if (type === "0" || type === "\0" || type === "") { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, buf.slice(off, off + size)); } off += Math.ceil(size / 512) * 512; } '@ | Set-Content -Path $extractor -Encoding ASCII & node $extractor $archive $unpack if ($LASTEXITCODE -ne 0) { Fail 'Could not unpack the bundle (corrupt download?).' } } # Accept either a wrapped (broodle-cli-\dist) or a flat (dist) tarball. $src = $unpack if (-not (Test-Path (Join-Path $src 'dist\cli.js'))) { $found = Get-ChildItem -Path $unpack -Filter cli.js -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Directory.Name -eq 'dist' } | Select-Object -First 1 if ($found) { $src = Split-Path (Split-Path $found.FullName -Parent) -Parent } } if (-not (Test-Path (Join-Path $src 'dist\cli.js'))) { Fail 'The bundle did not contain dist/cli.js.' } if (-not (Test-Path (Join-Path $src 'scripts\cli-entry.js'))) { Fail 'The bundle did not contain scripts/cli-entry.js.' } # Smoke-test the new build BEFORE it replaces anything. & node (Join-Path $src 'scripts\cli-entry.js') --version | Out-Null if ($LASTEXITCODE -ne 0) { Fail 'The downloaded build did not start on this machine, so it was not installed. Any existing install is untouched.' } New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'versions') | Out-Null if (Test-Path $versionDir) { Remove-Item $versionDir -Recurse -Force } Move-Item $src $versionDir Write-Ok "Installed $resolved to $versionDir" # Keep the two most recent builds so a rollback needs no download. Get-ChildItem -Path (Join-Path $InstallDir 'versions') -Directory | Sort-Object LastWriteTime -Descending | Select-Object -Skip 2 | ForEach-Object { Remove-Item $_.FullName -Recurse -Force } } # --- launcher ------------------------------------------------------------ # Windows has no dependable unprivileged symlink, so the .cmd names the # resolved version directly and is rewritten on every install. New-Item -ItemType Directory -Force -Path $BinDir | Out-Null $target = Join-Path $BinDir "$LauncherName.cmd" if ((Test-Path $target) -and (-not (Test-OurLauncher $target)) -and (-not $Force)) { Fail @" $target already exists and was not created by this installer. Choose one of: - install elsewhere: irm https://broodle.host/broodle-cli.ps1 -OutFile i.ps1; .\i.ps1 -BinDir C:\path\bin - replace that file: irm https://broodle.host/broodle-cli.ps1 -OutFile i.ps1; .\i.ps1 -Force - or move it aside yourself and re-run. "@ } @( '@echo off', ":: $LauncherMark - generated by https://broodle.host/broodle-cli.ps1", "node `"$entry`" %*" ) | Set-Content -Path $target -Encoding ASCII Write-Ok "Installed $target" # Retire launchers from older installers: an earlier version also wrote a # `broodle` alias pointing into a layout that no longer exists. foreach ($n in $LegacyNames) { $stale = Join-Path $BinDir "$n.cmd" if (($stale -ne $target) -and (Test-OurLauncher $stale)) { Remove-Item $stale -Force -ErrorAction SilentlyContinue Write-Warn "Removed the old '$n' launcher at $stale (the command is now 'broodle-cli')" } } # Final proof: it runs through the launcher we just wrote. $installedVersion = (& cmd /c "`"$target`"" --version 2>$null | Select-Object -Last 1) if (-not $installedVersion) { Fail "The launcher was written but did not run. Try: `"$target`" --version" } # --- PATH ---------------------------------------------------------------- $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $onPath = $false if ($userPath) { foreach ($p in ($userPath -split ';')) { if ($p -eq $BinDir) { $onPath = $true } } } if (-not $onPath) { if ([string]::IsNullOrEmpty($userPath)) { $newPath = $BinDir } else { $newPath = "$userPath;$BinDir" } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') $env:Path = "$env:Path;$BinDir" Write-Ok "Added $BinDir to your user PATH" Write-Dim 'Restart your terminal so the PATH change takes effect.' } Write-Host '' Write-Ok "Broodle CLI $installedVersion is ready" Write-Host '' Write-Host ' Next:' Write-Dim 'broodle-cli start an interactive session' Write-Dim 'broodle-cli -p "..." one-shot / headless' Write-Dim '/auth paste your bk_live_ API key' Write-Host '' Write-Host ' Get a key at https://broodle.host/console/studio/api-keys' Write-Host ' Docs at https://broodle.host/cli' Write-Host '' } finally { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue }