From 6874dff07e4f50c0e67fa45f14419711ee47d2c5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 3 Apr 2026 16:36:50 -0400 Subject: [PATCH 1/7] Improve Windows installation experience --- README.md | 10 +- install.md | 32 ++++-- scripts/ensure-basecamp.sh | 131 +++++++++++++++++++--- scripts/install.ps1 | 222 +++++++++++++++++++++++++++++++++++++ scripts/install.sh | 131 +++++++++++++++++++--- 5 files changed, 488 insertions(+), 38 deletions(-) create mode 100644 scripts/install.ps1 diff --git a/README.md b/README.md index 6a6418a9..0b94f6c4 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,18 @@ ## Quick Start +**macOS / Linux / WSL2** + ```bash curl -fsSL https://basecamp.com/install-cli | bash ``` +**Windows (PowerShell)** + +```powershell +irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex +``` + That's it. You now have full access to Basecamp from your terminal.
@@ -44,7 +52,7 @@ scoop bucket add basecamp https://github.com/basecamp/homebrew-tap scoop install basecamp-cli ``` -**Shell script:** +**Shell script (macOS/Linux/WSL2):** ```bash curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash ``` diff --git a/install.md b/install.md index 496db55e..9eebdde9 100644 --- a/install.md +++ b/install.md @@ -16,13 +16,21 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps auto ## Step 1: Install Basecamp CLI -Run this in your terminal: +Run the matching command for your environment: +**macOS / Linux / WSL / Git Bash** ```bash curl -fsSL https://basecamp.com/install-cli | bash ``` -> **Note:** The install script auto-detects non-interactive environments (CI, piped input, coding agents) and skips the interactive setup wizard. You can also explicitly skip it with `BASECAMP_SKIP_SETUP=1`. +**Windows PowerShell** +```powershell +irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex +``` + +> **Note:** The install scripts auto-detect non-interactive environments (CI, piped input, coding agents) and skip the interactive setup wizard. You can also explicitly skip it with `BASECAMP_SKIP_SETUP=1`. +> +> **Windows note:** if `curl` fails with a `schannel` / `CRYPT_E_NO_REVOCATION_CHECK` TLS error, prefer the PowerShell installer, Scoop, or Git Bash's `/usr/bin/curl` instead of the system `curl.exe`. Alternatively install manually: @@ -31,13 +39,18 @@ Alternatively install manually: brew install --cask basecamp/tap/basecamp-cli ``` -### Option B: Scoop (Windows) +### Option B: PowerShell (Windows) +```powershell +irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex +``` + +### Option C: Scoop (Windows) ```bash scoop bucket add basecamp https://github.com/basecamp/homebrew-tap scoop install basecamp-cli ``` -### Option C: Linux package (Debian/Ubuntu, Fedora/RHEL, Alpine) +### Option D: Linux package (Debian/Ubuntu, Fedora/RHEL, Alpine) ```bash # Download the matching package from https://github.com/basecamp/basecamp-cli/releases/latest sudo apt install ./basecamp-cli_*_linux_amd64.deb # Debian/Ubuntu @@ -46,17 +59,17 @@ sudo apk add --allow-untrusted ./basecamp-cli_*_linux_amd64.apk # Alpine ``` Arm64: substitute `arm64` for `amd64` in the filename. Verify the SHA-256 checksum from `checksums.txt` before installing unsigned Alpine packages. -### Option D: Nix +### Option E: Nix ```bash nix profile install github:basecamp/basecamp-cli ``` -### Option E: Go install +### Option F: Go install ```bash go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest ``` -### Option F: GitHub Release +### Option G: GitHub Release Download the archive for your platform from [Releases](https://github.com/basecamp/basecamp-cli/releases), extract, and move `basecamp` to a directory on your PATH. **Verify:** @@ -65,9 +78,10 @@ basecamp --version # Expected: basecamp version X.Y.Z ``` -If `basecamp: command not found`, add to PATH: +If `basecamp: command not found`, add it to PATH: ```bash -export PATH="$HOME/.local/bin:$PATH" +export PATH="$HOME/.local/bin:$PATH" # macOS / Linux / WSL +export PATH="$HOME/bin:$PATH" # Git Bash / Windows bash environments # or for go install: export PATH="$HOME/go/bin:$PATH" ``` diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index e2a8043f..118008a2 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -11,7 +11,10 @@ set -euo pipefail MIN_VERSION="${BASECAMP_MIN_VERSION:-0.1.0}" INSTALL_URL="https://github.com/basecamp/basecamp-cli" -BIN_DIR="${BASECAMP_BIN_DIR:-$HOME/.local/bin}" +BIN_DIR="${BASECAMP_BIN_DIR:-}" +CURL_SCHANNEL_FALLBACK_FLAG="" +CURL_LAST_ERROR="" +CURL_FALLBACK_NOTED=0 # Parse semver: returns 0 if $1 >= $2 version_gte() { @@ -19,6 +22,31 @@ version_gte() { printf '%s\n%s\n' "$v2" "$v1" | sort -V | head -1 | grep -qx "$v2" } +path_contains_dir() { + local dir="$1" + [[ ":$PATH:" == *":$dir:"* ]] +} + +default_bin_dir() { + local platform="$1" + + if path_contains_dir "$HOME/bin"; then + echo "$HOME/bin" + return 0 + fi + + if path_contains_dir "$HOME/.local/bin"; then + echo "$HOME/.local/bin" + return 0 + fi + + if [[ "$platform" == windows_* ]]; then + echo "$HOME/bin" + else + echo "$HOME/.local/bin" + fi +} + check_basecamp() { if ! command -v basecamp &>/dev/null; then echo "basecamp not found in PATH" @@ -60,6 +88,85 @@ detect_platform() { echo "${os}_${arch}" } +detect_curl_fallback() { + local version_output help_output + + version_output=$(curl --version 2>/dev/null || true) + if [[ "$version_output" != *[Ss]channel* ]]; then + return 0 + fi + + help_output=$(curl --help all 2>/dev/null || true) + if [[ "$help_output" == *"--ssl-revoke-best-effort"* ]]; then + CURL_SCHANNEL_FALLBACK_FLAG="--ssl-revoke-best-effort" + elif [[ "$help_output" == *"--ssl-no-revoke"* ]]; then + CURL_SCHANNEL_FALLBACK_FLAG="--ssl-no-revoke" + fi +} + +curl_run() { + local err_file status err + err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") + + if curl "$@" 2>"$err_file"; then + rm -f "$err_file" + CURL_LAST_ERROR="" + return 0 + else + status=$? + fi + + err=$(<"$err_file") + rm -f "$err_file" + + if [[ $status -ne 0 ]] && [[ -n "$CURL_SCHANNEL_FALLBACK_FLAG" ]] && [[ "$err" == *"CRYPT_E_NO_REVOCATION_CHECK"* ]]; then + if [[ $CURL_FALLBACK_NOTED -eq 0 ]]; then + echo "Retrying curl with ${CURL_SCHANNEL_FALLBACK_FLAG} because Windows certificate revocation checks are unavailable..." >&2 + CURL_FALLBACK_NOTED=1 + fi + + err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") + if curl "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then + rm -f "$err_file" + CURL_LAST_ERROR="" + return 0 + else + status=$? + fi + + err=$(<"$err_file") + rm -f "$err_file" + fi + + CURL_LAST_ERROR="$err" + return "$status" +} + +get_latest_version() { + local url version api_json + + if url=$(curl_run -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/basecamp/basecamp-cli/releases/latest"); then + version="${url##*/}" + version="${version#v}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi + fi + + if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest"); then + version="${api_json#*\"tag_name\":\"v}" + version="${version%%\"*}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi + fi + + echo "Could not determine latest version${CURL_LAST_ERROR:+ ($CURL_LAST_ERROR)}" >&2 + return 1 +} + install_basecamp() { echo "Installing basecamp..." @@ -67,16 +174,14 @@ install_basecamp() { # Get platform platform=$(detect_platform) || return 1 + detect_curl_fallback - # Get latest version via redirect (avoids GitHub API rate limits and grep/sed on Windows) - url=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/basecamp/basecamp-cli/releases/latest" 2>/dev/null) || true - version="${url##*/}" - version="${version#v}" - if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Could not determine latest version (resolved '${version:-}' from '${url:-}')" >&2 - return 1 + if [[ -z "$BIN_DIR" ]]; then + BIN_DIR=$(default_bin_dir "$platform") fi + version=$(get_latest_version) || return 1 + # Determine archive extension if [[ "$platform" == windows_* ]]; then ext="zip" @@ -90,10 +195,10 @@ install_basecamp() { echo "Downloading basecamp v${version} for ${platform}..." tmp_dir=$(mktemp -d) - trap 'rm -rf "$tmp_dir"' EXIT + trap "rm -rf '${tmp_dir}'" EXIT - if ! curl -fsSL "$url" -o "${tmp_dir}/${archive_name}"; then - echo "Failed to download from $url" >&2 + if ! curl_run -fsSL "$url" -o "${tmp_dir}/${archive_name}"; then + echo "Failed to download from $url${CURL_LAST_ERROR:+ ($CURL_LAST_ERROR)}" >&2 return 1 fi @@ -121,7 +226,7 @@ install_basecamp() { if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then echo "" echo "Add to your shell profile:" - echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + echo " export PATH=\"$BIN_DIR:\$PATH\"" fi } @@ -146,7 +251,7 @@ Usage: Environment: BASECAMP_MIN_VERSION Minimum required version (default: $MIN_VERSION) - BASECAMP_BIN_DIR Binary directory (default: ~/.local/bin) + BASECAMP_BIN_DIR Binary directory (default: ~/.local/bin, or ~/bin on Windows) EOF ;; *) diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..ff3946d9 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,222 @@ +$ErrorActionPreference = 'Stop' + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { + # Ignore when the runtime manages TLS defaults. +} + +$Repo = 'basecamp/basecamp-cli' +$Version = $env:BASECAMP_VERSION +$SkipSetup = $env:BASECAMP_SKIP_SETUP +$BinDir = $env:BASECAMP_BIN_DIR + +function Step([string]$Message) { + Write-Host " -> $Message" +} + +function Info([string]$Message) { + Write-Host " + $Message" -ForegroundColor Green +} + +function Fail([string]$Message) { + throw $Message +} + +function Get-PlatformArch { + $arch = $env:PROCESSOR_ARCHITECTURE + if ($env:PROCESSOR_ARCHITEW6432) { + $arch = $env:PROCESSOR_ARCHITEW6432 + } + + switch -Regex ($arch) { + '^(AMD64|x86_64)$' { return 'amd64' } + '^ARM64$' { return 'arm64' } + default { Fail "Unsupported Windows architecture: $arch" } + } +} + +function Get-LatestVersion { + Step 'Resolving latest release version...' + $release = Invoke-RestMethod -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } -Uri "https://api.github.com/repos/$Repo/releases/latest" + if (-not $release.tag_name) { + Fail 'Could not determine latest release version from GitHub.' + } + + return $release.tag_name.TrimStart('v') +} + +function Download-File([string]$Url, [string]$Destination) { + Invoke-WebRequest -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } -Uri $Url -OutFile $Destination +} + +function Verify-Checksum([string]$ChecksumsPath, [string]$ArchivePath, [string]$ArchiveName) { + $expected = $null + foreach ($line in Get-Content $ChecksumsPath) { + if ($line -match '^(?[0-9a-fA-F]{64})\s+\*?(?.+)$') { + if ($Matches.name -eq $ArchiveName) { + $expected = $Matches.hash.ToLowerInvariant() + break + } + } + } + + if (-not $expected) { + Fail "Could not find checksum entry for $ArchiveName" + } + + $actual = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Fail "Checksum verification failed for $ArchiveName" + } + + Info 'Checksum verified' +} + +function Get-PathEntries { + param([string]$PathValue) + + if (-not $PathValue) { + return @() + } + + return $PathValue -split ';' | Where-Object { $_ } +} + +function Normalize-PathEntry([string]$PathValue) { + if (-not $PathValue) { + return '' + } + + return $PathValue.Trim().TrimEnd('\\') +} + +function Get-DefaultBinDir { + $currentPathEntries = Get-PathEntries $env:Path + $userPathEntries = Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'User')) + $allEntries = @($currentPathEntries + $userPathEntries) | ForEach-Object { Normalize-PathEntry $_ } + + $homeBin = Normalize-PathEntry (Join-Path $HOME 'bin') + $homeLocalBin = Normalize-PathEntry (Join-Path $HOME '.local\bin') + + if ($allEntries -contains $homeBin) { + return $homeBin + } + + if ($allEntries -contains $homeLocalBin) { + return $homeLocalBin + } + + return $homeBin +} + +function Ensure-UserPath([string]$Dir) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $segments = Get-PathEntries $userPath + + $normalizedSegments = $segments | ForEach-Object { Normalize-PathEntry $_ } + $normalizedDir = Normalize-PathEntry $Dir + if ($normalizedSegments -contains $normalizedDir) { + return + } + + $newPath = if ($userPath) { "$userPath;$Dir" } else { $Dir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + $env:Path = "$Dir;$env:Path" + Info "Added $Dir to your user PATH" +} + +function Test-InteractiveSession { + if ($Host.Name -ne 'ConsoleHost' -and $Host.Name -ne 'Visual Studio Code Host') { + return $false + } + + try { + return -not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected + } catch { + return $false + } +} + +function Main { + $arch = Get-PlatformArch + if (-not $BinDir) { + $script:BinDir = Get-DefaultBinDir + } + + $resolvedVersion = if ($Version) { $Version } else { Get-LatestVersion } + + if ($resolvedVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$') { + Fail "Invalid version '$resolvedVersion'. Expected semver format like 1.2.3 or 1.2.3-rc.1." + } + + $archiveName = "basecamp_${resolvedVersion}_windows_${arch}.zip" + $baseUrl = "https://github.com/$Repo/releases/download/v$resolvedVersion" + + Step "Downloading basecamp v$resolvedVersion for windows_$arch..." + $tmpDir = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tmpDir | Out-Null + + try { + $archivePath = Join-Path $tmpDir $archiveName + $checksumsPath = Join-Path $tmpDir 'checksums.txt' + $extractDir = Join-Path $tmpDir 'extract' + + Download-File -Url "$baseUrl/$archiveName" -Destination $archivePath + + Step 'Verifying checksums...' + Download-File -Url "$baseUrl/checksums.txt" -Destination $checksumsPath + Verify-Checksum -ChecksumsPath $checksumsPath -ArchivePath $archivePath -ArchiveName $archiveName + + Step 'Extracting...' + Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force + + $binaryPath = Join-Path $extractDir 'basecamp.exe' + if (-not (Test-Path $binaryPath)) { + Fail 'basecamp.exe not found in archive' + } + + $installedBinary = Join-Path $BinDir 'basecamp.exe' + + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + Copy-Item -Force $binaryPath $installedBinary + Ensure-UserPath -Dir $BinDir + Info "Installed basecamp to $installedBinary" + + $installedVersion = & $installedBinary --version + Info "$installedVersion installed" + + $isInteractive = Test-InteractiveSession + + Write-Host '' + if ($SkipSetup -eq '1') { + Step 'Skipping setup wizard (BASECAMP_SKIP_SETUP=1)' + Write-Host '' + Write-Host ' Next steps:' + Write-Host ' basecamp auth login Authenticate with Basecamp' + Write-Host ' basecamp setup Run interactive setup wizard' + Write-Host '' + } elseif ($isInteractive) { + & $installedBinary setup + Write-Host '' + Write-Host ' Next steps:' + Write-Host ' basecamp auth login Authenticate with Basecamp' + Write-Host '' + } else { + Info 'Skipping interactive setup because PowerShell is running non-interactively.' + Write-Host '' + Write-Host ' Installed executable:' + Write-Host " $installedBinary" + Write-Host '' + Write-Host ' In this session, use the installed executable path directly for follow-up actions like starting login.' + Write-Host '' + } + } + finally { + if (Test-Path $tmpDir) { + Remove-Item -Recurse -Force $tmpDir + } + } +} + +Main diff --git a/scripts/install.sh b/scripts/install.sh index d6af895f..8b809d09 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,15 +5,18 @@ # curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash # # Options (via environment): -# BASECAMP_BIN_DIR Where to install binary (default: ~/.local/bin) +# BASECAMP_BIN_DIR Where to install binary (default: ~/.local/bin, or ~/bin on Windows) # BASECAMP_VERSION Specific version to install (default: latest) # BASECAMP_SKIP_SETUP Set to 1 to skip the interactive setup wizard after install set -euo pipefail REPO="basecamp/basecamp-cli" -BIN_DIR="${BASECAMP_BIN_DIR:-$HOME/.local/bin}" +BIN_DIR="${BASECAMP_BIN_DIR:-}" VERSION="${BASECAMP_VERSION:-}" +CURL_SCHANNEL_FALLBACK_FLAG="" +CURL_LAST_ERROR="" +CURL_FALLBACK_NOTED=0 # Color helpers — respect NO_COLOR (https://no-color.org) if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then @@ -42,6 +45,31 @@ find_sha256_cmd() { fi } +path_contains_dir() { + local dir="$1" + [[ ":$PATH:" == *":$dir:"* ]] +} + +default_bin_dir() { + local platform="$1" + + if path_contains_dir "$HOME/bin"; then + echo "$HOME/bin" + return 0 + fi + + if path_contains_dir "$HOME/.local/bin"; then + echo "$HOME/.local/bin" + return 0 + fi + + if [[ "$platform" == windows_* ]]; then + echo "$HOME/bin" + else + echo "$HOME/.local/bin" + fi +} + detect_platform() { local os arch @@ -65,17 +93,85 @@ detect_platform() { echo "${os}_${arch}" } +detect_curl_fallback() { + local version_output help_output + + version_output=$(curl --version 2>/dev/null || true) + if [[ "$version_output" != *[Ss]channel* ]]; then + return 0 + fi + + help_output=$(curl --help all 2>/dev/null || true) + if [[ "$help_output" == *"--ssl-revoke-best-effort"* ]]; then + CURL_SCHANNEL_FALLBACK_FLAG="--ssl-revoke-best-effort" + elif [[ "$help_output" == *"--ssl-no-revoke"* ]]; then + CURL_SCHANNEL_FALLBACK_FLAG="--ssl-no-revoke" + fi +} + +curl_run() { + local err_file status err + err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") + + if curl "$@" 2>"$err_file"; then + rm -f "$err_file" + CURL_LAST_ERROR="" + return 0 + else + status=$? + fi + + err=$(<"$err_file") + rm -f "$err_file" + + if [[ $status -ne 0 ]] && [[ -n "$CURL_SCHANNEL_FALLBACK_FLAG" ]] && [[ "$err" == *"CRYPT_E_NO_REVOCATION_CHECK"* ]]; then + if [[ $CURL_FALLBACK_NOTED -eq 0 ]]; then + echo " $(bold "→") Windows certificate revocation checks are unavailable; retrying curl with ${CURL_SCHANNEL_FALLBACK_FLAG}" >&2 + CURL_FALLBACK_NOTED=1 + fi + + err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") + if curl "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then + rm -f "$err_file" + CURL_LAST_ERROR="" + return 0 + else + status=$? + fi + + err=$(<"$err_file") + rm -f "$err_file" + fi + + CURL_LAST_ERROR="$err" + return "$status" +} + get_latest_version() { - local url version + local url version api_json + # Follow the releases/latest redirect to get the version from the final URL. # Avoids the GitHub API (no rate limiting) and grep/sed (better Windows compat). - url=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" 2>/dev/null) || true - version="${url##*/}" - version="${version#v}" - if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - error "Could not determine latest version (resolved '${version:-}' from '${url:-}'). Check your network connection or repository tags." + if url=$(curl_run -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest"); then + version="${url##*/}" + version="${version#v}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi fi - echo "$version" + + # Fallback to the GitHub API if redirect parsing fails. + if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/${REPO}/releases/latest"); then + version="${api_json#*\"tag_name\":\"v}" + version="${version%%\"*}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi + fi + + error "Could not determine latest version. ${CURL_LAST_ERROR:+curl said: ${CURL_LAST_ERROR}. }If you're on Windows, use Scoop or PowerShell, or try Git Bash's /usr/bin/curl." } verify_checksums() { @@ -85,8 +181,8 @@ verify_checksums() { local base_url="https://github.com/${REPO}/releases/download/v${version}" step "Verifying checksums..." - if ! curl -fsSL "${base_url}/checksums.txt" -o "${tmp_dir}/checksums.txt"; then - error "Failed to download checksums.txt" + if ! curl_run -fsSL "${base_url}/checksums.txt" -o "${tmp_dir}/checksums.txt"; then + error "Failed to download checksums.txt${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi # Verify SHA256 checksum of the downloaded archive @@ -102,8 +198,8 @@ verify_checksums() { if command -v cosign &>/dev/null; then step "Verifying cosign signature..." - if ! curl -fsSL "${base_url}/checksums.txt.bundle" -o "${tmp_dir}/checksums.txt.bundle"; then - error "Failed to download checksums.txt.bundle" + if ! curl_run -fsSL "${base_url}/checksums.txt.bundle" -o "${tmp_dir}/checksums.txt.bundle"; then + error "Failed to download checksums.txt.bundle${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi cosign verify-blob \ @@ -135,8 +231,8 @@ download_binary() { step "Downloading basecamp v${version} for ${platform}..." - if ! curl -fsSL "$url" -o "${tmp_dir}/${archive_name}"; then - error "Failed to download from $url" + if ! curl_run -fsSL "$url" -o "${tmp_dir}/${archive_name}"; then + error "Failed to download from $url${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" fi # Verify integrity before extraction @@ -305,6 +401,11 @@ main() { local platform version tmp_dir platform=$(detect_platform) + detect_curl_fallback + + if [[ -z "$BIN_DIR" ]]; then + BIN_DIR=$(default_bin_dir "$platform") + fi if [[ -n "$VERSION" ]]; then version="$VERSION" From a4039f90fc5c4351c5230bafcea2954693df15c3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 3 Apr 2026 16:51:48 -0400 Subject: [PATCH 2/7] Address installer review feedback --- scripts/ensure-basecamp.sh | 9 ++++++--- scripts/install.sh | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index 118008a2..608eab80 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -11,6 +11,7 @@ set -euo pipefail MIN_VERSION="${BASECAMP_MIN_VERSION:-0.1.0}" INSTALL_URL="https://github.com/basecamp/basecamp-cli" +REPO="basecamp/basecamp-cli" BIN_DIR="${BASECAMP_BIN_DIR:-}" CURL_SCHANNEL_FALLBACK_FLAG="" CURL_LAST_ERROR="" @@ -22,6 +23,8 @@ version_gte() { printf '%s\n%s\n' "$v2" "$v1" | sort -V | head -1 | grep -qx "$v2" } +# NOTE: Keep the installer helper functions below in sync with scripts/install.sh. +# These scripts stay self-contained on purpose so they can run without sourcing extra files. path_contains_dir() { local dir="$1" [[ ":$PATH:" == *":$dir:"* ]] @@ -145,7 +148,7 @@ curl_run() { get_latest_version() { local url version api_json - if url=$(curl_run -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/basecamp/basecamp-cli/releases/latest"); then + if url=$(curl_run -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest"); then version="${url##*/}" version="${version#v}" if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then @@ -154,7 +157,7 @@ get_latest_version() { fi fi - if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest"); then + if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/${REPO}/releases/latest"); then version="${api_json#*\"tag_name\":\"v}" version="${version%%\"*}" if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then @@ -190,7 +193,7 @@ install_basecamp() { fi archive_name="basecamp_${version}_${platform}.${ext}" - url="https://github.com/basecamp/basecamp-cli/releases/download/v${version}/${archive_name}" + url="https://github.com/${REPO}/releases/download/v${version}/${archive_name}" echo "Downloading basecamp v${version} for ${platform}..." diff --git a/scripts/install.sh b/scripts/install.sh index 8b809d09..be7b8980 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -45,6 +45,8 @@ find_sha256_cmd() { fi } +# NOTE: Keep the installer helper functions below in sync with scripts/ensure-basecamp.sh. +# These scripts stay self-contained on purpose so they can run without sourcing extra files. path_contains_dir() { local dir="$1" [[ ":$PATH:" == *":$dir:"* ]] @@ -171,7 +173,7 @@ get_latest_version() { fi fi - error "Could not determine latest version. ${CURL_LAST_ERROR:+curl said: ${CURL_LAST_ERROR}. }If you're on Windows, use Scoop or PowerShell, or try Git Bash's /usr/bin/curl." + error "Could not determine latest version. ${CURL_LAST_ERROR:+curl said: ${CURL_LAST_ERROR}. }If native Windows curl fails, try Scoop or PowerShell. If using Git Bash, try /usr/bin/curl instead." } verify_checksums() { From abad3b747c99ce20d5f65cd834550082c1cd0139 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 29 Apr 2026 11:08:10 -0700 Subject: [PATCH 3/7] Make ensure-basecamp.sh --install self-verify after a fresh install When the chosen bin dir wasn't already on PATH, install_basecamp printed the persistent-add instructions but didn't update PATH for the running script. The follow-up check_basecamp call then failed because `command -v basecamp` couldn't find it, so --install exited 1 on a genuinely successful install. Export BIN_DIR into PATH after the existing instructions block so the in-script check passes; the persistent-add guidance still prints because it inspects the original PATH. --- scripts/ensure-basecamp.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index 608eab80..a40b9a09 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -231,6 +231,9 @@ install_basecamp() { echo "Add to your shell profile:" echo " export PATH=\"$BIN_DIR:\$PATH\"" fi + + # Make the freshly installed binary visible to the in-script check_basecamp re-run. + export PATH="$BIN_DIR:$PATH" } main() { From 9fccdbf3175425618c49c7973b47b0d02e789772 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 29 Apr 2026 11:08:19 -0700 Subject: [PATCH 4/7] Harden Windows PowerShell installer Bring install.ps1 closer to install.sh's behavior on three security and reliability fronts, plus an actionable error for the most common Windows re-install failure. - Cosign signature verification when cosign.exe is on PATH, mirroring install.sh. Check $LASTEXITCODE explicitly because Windows PowerShell 5.1 doesn't surface native exits via $ErrorActionPreference=Stop, so a silent verify failure would otherwise false-green. - Surface an actionable error when basecamp.exe is locked. Windows holds an exclusive lock on running PE files; -Force doesn't help. Wrap the copy in a generic catch (typed catches miss the ActionPreferenceStopException wrapper) and include the original exception text so unrelated failures aren't masked. - Try the GitHub releases/latest redirect before the API to avoid unauthenticated rate limits, falling back to the API on miss. Read Location off the response in both the success and 302-as-error paths so it works on Windows PowerShell 5.1 and PowerShell Core. - Prepend BIN_DIR to the persisted user PATH so a stale basecamp.exe earlier in PATH doesn't shadow the freshly-installed one in new shells. --- scripts/install.ps1 | 66 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ff3946d9..0d87b568 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -38,6 +38,35 @@ function Get-PlatformArch { function Get-LatestVersion { Step 'Resolving latest release version...' + + # Follow the releases/latest redirect first to avoid GitHub API rate limits. + # -MaximumRedirection 0 turns the expected 302 into a terminating error, so + # we read Location off the caught response. Headers.Location is Uri on + # PowerShell Core and string on Windows PowerShell 5.1, so coerce to string. + $location = $null + try { + $response = Invoke-WebRequest -MaximumRedirection 0 -UseBasicParsing ` + -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } ` + -Uri "https://github.com/$Repo/releases/latest" -ErrorAction Stop + $location = $response.Headers.Location + } catch { + if ($_.Exception.Response) { + $location = $_.Exception.Response.Headers.Location + if (-not $location) { + $location = $_.Exception.Response.Headers['Location'] + } + } + } + + if ($location) { + $tag = ([string]$location).TrimEnd('/').Split('/')[-1] + $candidate = $tag.TrimStart('v') + if ($candidate -match '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$') { + return $candidate + } + } + + # Fall back to the GitHub API if the redirect path didn't yield a semver tag. $release = Invoke-RestMethod -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } -Uri "https://api.github.com/repos/$Repo/releases/latest" if (-not $release.tag_name) { Fail 'Could not determine latest release version from GitHub.' @@ -73,6 +102,31 @@ function Verify-Checksum([string]$ChecksumsPath, [string]$ArchivePath, [string]$ Info 'Checksum verified' } +function Verify-CosignSignature([string]$Version, [string]$BaseUrl, [string]$TmpDir) { + if (-not (Get-Command cosign -ErrorAction SilentlyContinue)) { + return + } + + Step 'Verifying cosign signature...' + + $bundlePath = Join-Path $TmpDir 'checksums.txt.bundle' + $checksumsPath = Join-Path $TmpDir 'checksums.txt' + Download-File -Url "$BaseUrl/checksums.txt.bundle" -Destination $bundlePath + + # Native exits don't trigger ErrorActionPreference=Stop on Windows PowerShell 5.1, + # so check $LASTEXITCODE explicitly — otherwise a verify failure would false-green. + & cosign verify-blob ` + --bundle $bundlePath ` + --certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v$Version" ` + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ` + $checksumsPath + if ($LASTEXITCODE -ne 0) { + Fail 'Cosign signature verification failed' + } + + Info 'Signature verified' +} + function Get-PathEntries { param([string]$PathValue) @@ -120,7 +174,7 @@ function Ensure-UserPath([string]$Dir) { return } - $newPath = if ($userPath) { "$userPath;$Dir" } else { $Dir } + $newPath = if ($userPath) { "$Dir;$userPath" } else { $Dir } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') $env:Path = "$Dir;$env:Path" Info "Added $Dir to your user PATH" @@ -168,6 +222,8 @@ function Main { Download-File -Url "$baseUrl/checksums.txt" -Destination $checksumsPath Verify-Checksum -ChecksumsPath $checksumsPath -ArchivePath $archivePath -ArchiveName $archiveName + Verify-CosignSignature -Version $resolvedVersion -BaseUrl $baseUrl -TmpDir $tmpDir + Step 'Extracting...' Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force @@ -179,7 +235,13 @@ function Main { $installedBinary = Join-Path $BinDir 'basecamp.exe' New-Item -ItemType Directory -Force -Path $BinDir | Out-Null - Copy-Item -Force $binaryPath $installedBinary + # Windows holds an exclusive lock on running PE files; -Force doesn't help. + # Generic catch — typed catches miss ActionPreferenceStopException wrapping. + try { + Copy-Item -Force $binaryPath $installedBinary -ErrorAction Stop + } catch { + Fail "basecamp.exe is in use. Close any running 'basecamp' processes and re-run the installer. (Original error: $($_.Exception.Message))" + } Ensure-UserPath -Dir $BinDir Info "Installed basecamp to $installedBinary" From 97f932ecf818cf4bd682747dc894ee7ff0c3d814 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 29 Apr 2026 11:16:58 -0700 Subject: [PATCH 5/7] Address inline review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mention Git Bash alongside macOS/Linux/WSL2 for the shell installer in README so it matches install.md and reflects this PR's Schannel hardening. - Document the PATH-aware BASECAMP_BIN_DIR default in both install.sh and ensure-basecamp.sh — default_bin_dir() prefers ~/bin or ~/.local/bin if already on PATH before falling back to a platform default. - Soften the install.ps1 Copy-Item failure message: lead with the generic install failure, suggest closing running processes if the file is in use. The original exception text is still appended so disk-full or permission failures aren't masked. --- README.md | 2 +- scripts/ensure-basecamp.sh | 4 +++- scripts/install.ps1 | 2 +- scripts/install.sh | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0b94f6c4..84a51fcc 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ scoop bucket add basecamp https://github.com/basecamp/homebrew-tap scoop install basecamp-cli ``` -**Shell script (macOS/Linux/WSL2):** +**Shell script (macOS / Linux / WSL2 / Git Bash):** ```bash curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash ``` diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index a40b9a09..3c2894b1 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -257,7 +257,9 @@ Usage: Environment: BASECAMP_MIN_VERSION Minimum required version (default: $MIN_VERSION) - BASECAMP_BIN_DIR Binary directory (default: ~/.local/bin, or ~/bin on Windows) + BASECAMP_BIN_DIR Binary directory + (default: ~/bin if on PATH, else ~/.local/bin if on PATH; + otherwise ~/bin on Windows, ~/.local/bin elsewhere) EOF ;; *) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0d87b568..fb9e020a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -240,7 +240,7 @@ function Main { try { Copy-Item -Force $binaryPath $installedBinary -ErrorAction Stop } catch { - Fail "basecamp.exe is in use. Close any running 'basecamp' processes and re-run the installer. (Original error: $($_.Exception.Message))" + Fail "Failed to install basecamp.exe. If it is in use, close any running 'basecamp' processes and re-run the installer. (Original error: $($_.Exception.Message))" } Ensure-UserPath -Dir $BinDir Info "Installed basecamp to $installedBinary" diff --git a/scripts/install.sh b/scripts/install.sh index be7b8980..a050eaba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,7 +5,9 @@ # curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash # # Options (via environment): -# BASECAMP_BIN_DIR Where to install binary (default: ~/.local/bin, or ~/bin on Windows) +# BASECAMP_BIN_DIR Where to install binary +# (default: ~/bin if on PATH, else ~/.local/bin if on PATH; +# otherwise ~/bin on Windows, ~/.local/bin elsewhere) # BASECAMP_VERSION Specific version to install (default: latest) # BASECAMP_SKIP_SETUP Set to 1 to skip the interactive setup wizard after install From 94f26305641529cf5bcaffeedbd578d45e406285 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 29 Apr 2026 12:31:16 -0700 Subject: [PATCH 6/7] Make API fallback parse whitespace-tolerant Bash parameter expansion `${api_json#*\"tag_name\":\"v}` only matched the exact `\"tag_name\":\"v...\"` shape. GitHub currently returns minified JSON under `Accept: application/vnd.github+json`, so the parse worked, but a future format change or alternate endpoint that pretty-printed would have silently fallen through (the trailing semver guard would catch the garbage, but the fallback would be unusable). Replace with a bash regex that tolerates spaces, tabs, and newlines around the colon and makes the leading `v` optional. The regex is anchored on the unescaped quotes around `tag_name`, so a release body containing the substring `\"tag_name\":\"...\"` (which gets backslash-escaped in JSON) can't false-match. Stays in pure bash so it works on macOS' shipped /bin/bash 3.2 and Git Bash without depending on GNU-awk's 3-arg match(). --- scripts/ensure-basecamp.sh | 14 +++++++++----- scripts/install.sh | 15 +++++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index 3c2894b1..021b4368 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -157,12 +157,16 @@ get_latest_version() { fi fi + # Whitespace-tolerant regex so a future GitHub format change (pretty-print, + # extra spaces) doesn't silently break the fallback. Pure bash so no GNU-awk + # dependency. if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/${REPO}/releases/latest"); then - version="${api_json#*\"tag_name\":\"v}" - version="${version%%\"*}" - if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "$version" - return 0 + if [[ $api_json =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"v?([^\"]+)\" ]]; then + version="${BASH_REMATCH[1]}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi fi fi diff --git a/scripts/install.sh b/scripts/install.sh index a050eaba..7e9fb315 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -165,13 +165,16 @@ get_latest_version() { fi fi - # Fallback to the GitHub API if redirect parsing fails. + # Fallback to the GitHub API if redirect parsing fails. Whitespace-tolerant + # regex so a future GitHub format change (pretty-print, extra spaces) doesn't + # silently break the fallback. Pure bash so no GNU-awk dependency. if api_json=$(curl_run -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: basecamp-cli-installer' "https://api.github.com/repos/${REPO}/releases/latest"); then - version="${api_json#*\"tag_name\":\"v}" - version="${version%%\"*}" - if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "$version" - return 0 + if [[ $api_json =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"v?([^\"]+)\" ]]; then + version="${BASH_REMATCH[1]}" + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$version" + return 0 + fi fi fi From cd75c3bc9f2444008c1fa559d60933fb22ffb324 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 29 Apr 2026 12:46:46 -0700 Subject: [PATCH 7/7] Address inline review feedback (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - curl_run: force --show-error inside the helper. All current callers pass -fsSL (which already includes -S), so behavior is unchanged today, but encoding the flag in the helper makes the Schannel revocation fallback invariant — "errors must reach stderr so we can grep for CRYPT_E_NO_REVOCATION_CHECK" — robust to a future caller that uses -s alone. - install.ps1 Download-File: pass -UseBasicParsing -ErrorAction Stop. On Windows PowerShell 5.1 the cmdlet initializes IE's MSHTML parser even for -OutFile downloads, which fails on Server Core / locked-down installs. -UseBasicParsing is a no-op on PowerShell 6+. -ErrorAction Stop doubles up on the global $ErrorActionPreference for clarity and defense if a future function locally relaxes it. - install.ps1 Get-LatestVersion API fallback: same -ErrorAction Stop hardening on Invoke-RestMethod for consistency. - install.md: align Step 1 heading with README — "WSL2 / Git Bash" instead of "WSL / Git Bash". The PR description and README both say WSL2. --- install.md | 2 +- scripts/ensure-basecamp.sh | 8 ++++++-- scripts/install.ps1 | 11 +++++++++-- scripts/install.sh | 8 ++++++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/install.md b/install.md index 9eebdde9..e667e0aa 100644 --- a/install.md +++ b/install.md @@ -18,7 +18,7 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps auto Run the matching command for your environment: -**macOS / Linux / WSL / Git Bash** +**macOS / Linux / WSL2 / Git Bash** ```bash curl -fsSL https://basecamp.com/install-cli | bash ``` diff --git a/scripts/ensure-basecamp.sh b/scripts/ensure-basecamp.sh index 021b4368..75ae456b 100755 --- a/scripts/ensure-basecamp.sh +++ b/scripts/ensure-basecamp.sh @@ -108,10 +108,14 @@ detect_curl_fallback() { } curl_run() { + # --show-error guarantees curl writes errors to stderr even if a future caller + # passes -s without -S. The Schannel revocation detection below depends on + # finding CRYPT_E_NO_REVOCATION_CHECK in stderr; without --show-error a -s + # caller would silently lose the fallback. local err_file status err err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") - if curl "$@" 2>"$err_file"; then + if curl --show-error "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0 @@ -129,7 +133,7 @@ curl_run() { fi err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") - if curl "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then + if curl --show-error "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fb9e020a..e8eeb70f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -67,7 +67,9 @@ function Get-LatestVersion { } # Fall back to the GitHub API if the redirect path didn't yield a semver tag. - $release = Invoke-RestMethod -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } -Uri "https://api.github.com/repos/$Repo/releases/latest" + $release = Invoke-RestMethod -ErrorAction Stop ` + -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } ` + -Uri "https://api.github.com/repos/$Repo/releases/latest" if (-not $release.tag_name) { Fail 'Could not determine latest release version from GitHub.' } @@ -76,7 +78,12 @@ function Get-LatestVersion { } function Download-File([string]$Url, [string]$Destination) { - Invoke-WebRequest -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } -Uri $Url -OutFile $Destination + # -UseBasicParsing avoids initializing IE's MSHTML parser on Windows + # PowerShell 5.1 — required on Server Core and locked-down installs. + # No-op on PowerShell 6+, where basic parsing is the only mode. + Invoke-WebRequest -UseBasicParsing -ErrorAction Stop ` + -Headers @{ 'User-Agent' = 'basecamp-cli-installer' } ` + -Uri $Url -OutFile $Destination } function Verify-Checksum([string]$ChecksumsPath, [string]$ArchivePath, [string]$ArchiveName) { diff --git a/scripts/install.sh b/scripts/install.sh index 7e9fb315..55e8514e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -114,10 +114,14 @@ detect_curl_fallback() { } curl_run() { + # --show-error guarantees curl writes errors to stderr even if a future caller + # passes -s without -S. The Schannel revocation detection below depends on + # finding CRYPT_E_NO_REVOCATION_CHECK in stderr; without --show-error a -s + # caller would silently lose the fallback. local err_file status err err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") - if curl "$@" 2>"$err_file"; then + if curl --show-error "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0 @@ -135,7 +139,7 @@ curl_run() { fi err_file=$(mktemp "${TMPDIR:-/tmp}/basecamp-curl.XXXXXX") - if curl "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then + if curl --show-error "$CURL_SCHANNEL_FALLBACK_FLAG" "$@" 2>"$err_file"; then rm -f "$err_file" CURL_LAST_ERROR="" return 0