From d91edc1a3051a0f9df68a7883087685cc50240a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:04:13 +0000 Subject: [PATCH 1/4] Add checksum-verified install scripts for standalone binaries Add install.sh (Linux and macOS) and install.ps1 (Windows) that download the standalone binary from GitHub Releases, verify its SHA-256 checksum against the release's checksums.txt in a temporary directory before installing, and run seam completion --install. The install directory defaults to an XDG-style cascade: SEAM_BIN_PATH, then XDG_BIN_HOME, then XDG_DATA_HOME/../bin, then ~/.local/bin (LOCALAPPDATA\seam\bin on Windows). Flags: --version to pin a release, --bin-path to choose the directory, and --no-install-completion to leave shell configuration untouched. Test the scripts on Linux, macOS, and Windows in a dedicated workflow that runs only when the scripts change, including negative tests that a checksum mismatch refuses to install. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtYdE6UGg6ZR7ieqS5YpHt --- .github/workflows/install-script.yml | 131 +++++++++++ README.md | 35 ++- install.ps1 | 156 +++++++++++++ install.sh | 320 +++++++++++++++++++++++++++ 4 files changed, 637 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/install-script.yml create mode 100644 install.ps1 create mode 100644 install.sh diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 00000000..8619d2d1 --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,131 @@ +--- +name: Install script + +on: + push: + branches: + - main + paths: + - install.sh + - install.ps1 + - .github/workflows/install-script.yml + pull_request: + branches: + - '**' + paths: + - install.sh + - install.ps1 + - .github/workflows/install-script.yml + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Shellcheck + run: shellcheck install.sh + test: + name: Test (${{ matrix.os_name }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + include: + - os: ubuntu-latest + os_name: Linux + - os: macos-latest + os_name: macOS + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install latest release + run: | + sh install.sh + "$HOME/.local/bin/seam" --version + - name: Check installed completions + if: ${{ runner.os == 'Linux' }} + run: grep 'seam completion --loader' "$HOME/.bashrc" + - name: Install pinned version to a custom directory + run: | + sh install.sh --version v0.29.0 --bin-path "$RUNNER_TEMP/seam-bin" --no-install-completion + "$RUNNER_TEMP/seam-bin/seam" --version | grep -F 0.29.0 + - name: Install to XDG_BIN_HOME + run: | + XDG_BIN_HOME="$RUNNER_TEMP/xdg-bin" SEAM_NO_COMPLETIONS=1 sh install.sh + "$RUNNER_TEMP/xdg-bin/seam" --version + - name: Refuse to install on checksum mismatch + run: | + os=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$(uname -m)" in + x86_64 | amd64) arch=x64 ;; + arm64 | aarch64) arch=arm64 ;; + esac + asset="seam-v0.0.1-${os}-${arch}" + mkdir -p "$RUNNER_TEMP/srv/v0.0.1" + echo 'not the real binary' > "$RUNNER_TEMP/srv/v0.0.1/$asset" + printf '%s %s\n' \ + '0000000000000000000000000000000000000000000000000000000000000000' \ + "$asset" > "$RUNNER_TEMP/srv/v0.0.1/checksums.txt" + python3 -m http.server 8931 --directory "$RUNNER_TEMP/srv" & + server_pid=$! + sleep 2 + if SEAM_DOWNLOAD_URL=http://127.0.0.1:8931 \ + sh install.sh --version v0.0.1 --bin-path "$RUNNER_TEMP/tamper-bin" \ + --no-install-completion; then + echo 'The install must fail on a checksum mismatch' >&2 + kill "$server_pid" + exit 1 + fi + kill "$server_pid" + if [ -e "$RUNNER_TEMP/tamper-bin/seam" ]; then + echo 'Nothing must be installed on a checksum mismatch' >&2 + exit 1 + fi + test_windows: + name: Test (Windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install latest release + run: | + .\install.ps1 + seam --version + - name: Install pinned version to a custom directory + run: | + .\install.ps1 -Version v0.29.0 -BinPath "$env:RUNNER_TEMP\seam-bin" + $version = & "$env:RUNNER_TEMP\seam-bin\seam.exe" --version + if ($version -notmatch '0\.29\.0') { + throw "Unexpected version: $version" + } + - name: Refuse to install on checksum mismatch + run: | + $srv = "$env:RUNNER_TEMP\srv\v0.0.1" + New-Item -ItemType Directory -Path $srv -Force | Out-Null + Set-Content -Path "$srv\seam-v0.0.1-windows-x64.exe" -Value 'not the real binary' + Set-Content -Path "$srv\checksums.txt" -Value ('0' * 64 + ' seam-v0.0.1-windows-x64.exe') + $server = Start-Process python -ArgumentList '-m', 'http.server', '8931', '--directory', "$env:RUNNER_TEMP\srv" -PassThru -NoNewWindow + Start-Sleep -Seconds 2 + $env:SEAM_DOWNLOAD_URL = 'http://127.0.0.1:8931' + $failed = $false + try { + .\install.ps1 -Version v0.0.1 -BinPath "$env:RUNNER_TEMP\tamper-bin" + } catch { + $failed = $true + } + $env:SEAM_DOWNLOAD_URL = '' + Stop-Process -Id $server.Id -Force + if (-not $failed) { + throw 'The install must fail on a checksum mismatch' + } + if (Test-Path "$env:RUNNER_TEMP\tamper-bin\seam.exe") { + throw 'Nothing must be installed on a checksum mismatch' + } diff --git a/README.md b/README.md index 589b2cff..5e6222a9 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,37 @@ $ brew install seam ### Standalone binary -Download a standalone binary for your platform from the [latest GitHub -release]. The macOS binaries are signed with the Seam Labs, Inc. Apple -Developer ID and notarized by Apple, so macOS runs them without a Gatekeeper -prompt. +Install the latest release on Linux and macOS with -Then install the shell completions with +``` +$ curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh | sh +``` + +or on Windows with + +``` +> iwr -useb https://raw.githubusercontent.com/seamapi/cli/main/install.ps1 | iex +``` + +The script downloads the binary for your platform, verifies its SHA-256 +checksum against the release's `checksums.txt` before installing, and runs +`seam completion --install`. It installs to the first of `$SEAM_BIN_PATH`, +`$XDG_BIN_HOME`, `$XDG_DATA_HOME/../bin`, or `~/.local/bin` +(`%LOCALAPPDATA%\seam\bin` on Windows). Pass `--version ` to pin a +version, `--bin-path ` to choose the install directory, and +`--no-install-completion` to leave your shell configuration untouched, e.g. + +``` +$ curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh \ + | sh -s -- --version v0.29.0 --no-install-completion +``` + +Alternatively, download a standalone binary for your platform from the +[latest GitHub release]. The macOS binaries are signed with the Seam Labs, +Inc. Apple Developer ID and notarized by Apple, so macOS runs them without a +Gatekeeper prompt. + +After a manual download, install the shell completions with ``` $ seam completion --install diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..00a60c17 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,156 @@ +<# +.SYNOPSIS +Install the Seam CLI on Windows. + +.DESCRIPTION +Downloads the standalone seam binary from GitHub Releases, verifies its +SHA-256 checksum against the release's checksums.txt in a temporary +directory, installs it, and adds the install directory to the user PATH. + +Run directly from the web (parameters are not available this way, use the +environment variables instead): + + iwr -useb https://raw.githubusercontent.com/seamapi/cli/main/install.ps1 | iex + +Or download first to pass parameters: + + iwr -useb https://raw.githubusercontent.com/seamapi/cli/main/install.ps1 -OutFile install.ps1 + .\install.ps1 -Version v0.29.0 + +.PARAMETER Version +Version to install, e.g. v0.29.0 (default: latest). + +.PARAMETER BinPath +Install directory (default: SEAM_BIN_PATH environment variable, then +%LOCALAPPDATA%\seam\bin). + +.PARAMETER NoInstallCompletion +Accepted for symmetry with install.sh. Shell completions support bash, fish, +and zsh only, so they are never installed on Windows. + +.NOTES +Environment variables: SEAM_BIN_PATH (install directory), SEAM_DOWNLOAD_URL +(base URL for release downloads, for mirrors and testing). +#> +[CmdletBinding()] +param( + [string]$Version = '', + [string]$BinPath = '', + [switch]$NoInstallCompletion +) + +$ErrorActionPreference = 'Stop' + +$AppName = 'seam' +$Repo = 'seamapi/cli' +$DownloadBase = if ($env:SEAM_DOWNLOAD_URL) { + $env:SEAM_DOWNLOAD_URL +} else { + "https://github.com/$Repo/releases/download" +} + +# Windows PowerShell 5.1 defaults to TLS 1.0, which GitHub rejects. +if ($PSVersionTable.PSVersion.Major -lt 6) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +} + +function Get-LatestTag { + $release = Invoke-RestMethod -UseBasicParsing ` + -Uri "https://api.github.com/repos/$Repo/releases/latest" + return $release.tag_name +} + +function Test-IsWindows { + # $IsWindows does not exist on Windows PowerShell 5.1. + return ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows +} + +if (Test-IsWindows) { + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -eq 'ARM64') { + Write-Host 'No arm64 binary is published for Windows: installing the x64 binary, which runs under emulation.' + } elseif ($arch -ne 'AMD64') { + throw "Unsupported architecture: $arch (supported: AMD64, ARM64)." + } +} + +if (-not $Version) { + $Version = Get-LatestTag +} elseif ($Version -notmatch '^v') { + $Version = "v$Version" +} + +if (-not $BinPath) { + if ($env:SEAM_BIN_PATH) { + $BinPath = $env:SEAM_BIN_PATH + } elseif ($env:LOCALAPPDATA) { + $BinPath = Join-Path $env:LOCALAPPDATA "$AppName\bin" + } else { + throw 'Cannot determine the install directory: set SEAM_BIN_PATH or LOCALAPPDATA.' + } +} + +$asset = "$AppName-$Version-windows-x64.exe" +$assetUrl = "$DownloadBase/$Version/$asset" +$checksumsUrl = "$DownloadBase/$Version/checksums.txt" + +$tmpDir = Join-Path ([IO.Path]::GetTempPath()) "$AppName-install-$([Guid]::NewGuid())" +New-Item -ItemType Directory -Path $tmpDir | Out-Null + +try { + $assetFile = Join-Path $tmpDir $asset + $checksumsFile = Join-Path $tmpDir 'checksums.txt' + + Write-Host "Downloading $assetUrl" + Invoke-WebRequest -UseBasicParsing -Uri $assetUrl -OutFile $assetFile + + Write-Host "Verifying the checksum with $checksumsUrl" + Invoke-WebRequest -UseBasicParsing -Uri $checksumsUrl -OutFile $checksumsFile + + # checksums.txt is in sha256sum format: ' ' per line. + $expected = '' + foreach ($line in Get-Content $checksumsFile) { + $parts = $line.Trim() -split '\s+', 2 + if ($parts.Count -eq 2 -and $parts[1].TrimStart('*') -eq $asset) { + $expected = $parts[0] + break + } + } + if (-not $expected) { + throw "$asset is not listed in checksums.txt: refusing to install." + } + $actual = (Get-FileHash -Path $assetFile -Algorithm SHA256).Hash + if ($actual -ne $expected) { + throw "Checksum mismatch for ${asset}: refusing to install.`n expected: $expected`n actual: $actual" + } + + New-Item -ItemType Directory -Path $BinPath -Force | Out-Null + $target = Join-Path $BinPath "$AppName.exe" + Move-Item -Path $assetFile -Destination $target -Force + Write-Host "Installed $target ($Version)" +} finally { + Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue +} + +# Add the install directory to the user PATH (registry) and to the current +# session, so seam is available in new shells without manual setup. +if (Test-IsWindows) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not $userPath) { $userPath = '' } + $onPath = ($userPath -split ';' | Where-Object { $_ -eq $BinPath }).Count -gt 0 + if (-not $onPath) { + $newPath = if ($userPath) { "$userPath;$BinPath" } else { $BinPath } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Host "Added $BinPath to the user PATH. Open a new shell to use $AppName." + } +} +if (($env:PATH -split [IO.Path]::PathSeparator) -notcontains $BinPath) { + $env:PATH = "$BinPath$([IO.Path]::PathSeparator)$env:PATH" +} +if ($env:GITHUB_PATH) { + Add-Content -Path $env:GITHUB_PATH -Value $BinPath +} + +if (-not $NoInstallCompletion) { + Write-Host "Shell completions support bash, fish, and zsh only, so they are not installed on Windows. In one of those shells, run: $AppName completion --install" +} diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..d18d0095 --- /dev/null +++ b/install.sh @@ -0,0 +1,320 @@ +#!/bin/sh +# Seam CLI installer. +# +# Downloads the standalone seam binary for this platform from GitHub Releases, +# verifies its SHA-256 checksum against the release's checksums.txt in a +# temporary directory, installs it, and installs shell completions. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh \ +# | sh -s -- --version v0.29.0 --no-install-completion +# +# Options: +# --version Version to install, e.g. v0.29.0 (default: latest). +# --bin-path Install directory (default: SEAM_BIN_PATH, then +# XDG_BIN_HOME, then XDG_DATA_HOME/../bin, then +# ~/.local/bin). +# --no-install-completion Do not run `seam completion --install`. +# -h, --help Show this help. +# +# Environment: +# SEAM_BIN_PATH Install directory (same as --bin-path). +# SEAM_NO_COMPLETIONS Set to 1 to skip completions (same as +# --no-install-completion). +# SEAM_DOWNLOAD_URL Base URL for release downloads, for mirrors and +# testing (default: +# https://github.com/seamapi/cli/releases/download). +# +# On Windows, use install.ps1 instead: +# iwr -useb https://raw.githubusercontent.com/seamapi/cli/main/install.ps1 | iex + +set -u + +APP_NAME='seam' +REPO='seamapi/cli' +DOWNLOAD_BASE="${SEAM_DOWNLOAD_URL:-https://github.com/${REPO}/releases/download}" +LATEST_RELEASE_URL="https://github.com/${REPO}/releases/latest" + +usage() { + cat < Version to install, e.g. v0.29.0 (default: latest). + --bin-path Install directory (default: SEAM_BIN_PATH, then + XDG_BIN_HOME, then XDG_DATA_HOME/../bin, then + ~/.local/bin). + --no-install-completion Do not run 'seam completion --install'. + -h, --help Show this help. + +Environment: + SEAM_BIN_PATH Install directory (same as --bin-path). + SEAM_NO_COMPLETIONS Set to 1 to skip completions. + SEAM_DOWNLOAD_URL Base URL for release downloads (mirrors and testing). +EOF +} + +say() { + echo "install.sh: $1" +} + +warn() { + echo "install.sh: warning: $1" >&2 +} + +err() { + echo "install.sh: error: $1" >&2 + exit 1 +} + +check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +# Pick curl or wget for all downloads. +detect_downloader() { + if check_cmd curl; then + _downloader=curl + elif check_cmd wget; then + _downloader=wget + else + err "need 'curl' or 'wget' (command not found)" + fi +} + +# download +# +# Enforce HTTPS against GitHub. When SEAM_DOWNLOAD_URL is set the user opted +# in to their own base URL, so other protocols are allowed for mirrors and +# local testing. +download() { + if [ "$_downloader" = curl ]; then + if [ -n "${SEAM_DOWNLOAD_URL:-}" ]; then + curl -fsSL "$1" -o "$2" + else + curl --proto '=https' --tlsv1.2 -fsSL "$1" -o "$2" + fi + else + wget -q "$1" -O "$2" + fi +} + +# Resolve the latest release tag by following the releases/latest redirect. +# This avoids the GitHub API and its rate limits. +resolve_latest_tag() { + if [ "$_downloader" = curl ]; then + _redirect_url=$(curl --proto '=https' --tlsv1.2 -fsSLI \ + -o /dev/null -w '%{url_effective}' "$LATEST_RELEASE_URL") || + err "cannot resolve the latest release of ${REPO}" + else + _redirect_url=$(wget --spider --server-response "$LATEST_RELEASE_URL" 2>&1 | + grep -i '^ *location: ' | tail -n 1 | awk '{print $2}' | tr -d '\r') || + err "cannot resolve the latest release of ${REPO}" + fi + case "$_redirect_url" in + */releases/tag/*) _tag="${_redirect_url##*/releases/tag/}" ;; + *) err "cannot parse the latest release tag from ${_redirect_url}" ;; + esac +} + +detect_platform() { + _os_name=$(uname -s) + case "$_os_name" in + Darwin) _os=darwin ;; + Linux) _os=linux ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + err "use install.ps1 on Windows: + iwr -useb https://raw.githubusercontent.com/${REPO}/main/install.ps1 | iex" + ;; + *) err "unsupported operating system: ${_os_name} (supported: Linux, macOS, Windows)" ;; + esac + + _arch_name=$(uname -m) + case "$_arch_name" in + x86_64 | amd64) _arch=x64 ;; + arm64 | aarch64) _arch=arm64 ;; + *) err "unsupported architecture: ${_arch_name} (supported: x64, arm64)" ;; + esac +} + +sha256() { + if check_cmd sha256sum; then + sha256sum "$1" | awk '{print $1}' + elif check_cmd gsha256sum; then + gsha256sum "$1" | awk '{print $1}' + elif check_cmd shasum; then + shasum -a 256 "$1" | awk '{print $1}' + elif check_cmd openssl; then + openssl dgst -sha256 "$1" | awk '{print $NF}' + else + err "need a SHA-256 tool: sha256sum, gsha256sum, shasum, or openssl (command not found)" + fi +} + +get_home() { + if [ -n "${HOME:-}" ]; then + echo "$HOME" + elif check_cmd getent && check_cmd id; then + getent passwd "$(id -u)" | cut -d: -f6 + else + err "cannot determine the home directory: HOME is not set" + fi +} + +# Install directory cascade: +# --bin-path, SEAM_BIN_PATH, XDG_BIN_HOME, XDG_DATA_HOME/../bin, ~/.local/bin. +resolve_bin_dir() { + if [ -n "$_bin_path" ]; then + _bin_dir="$_bin_path" + elif [ -n "${SEAM_BIN_PATH:-}" ]; then + _bin_dir="$SEAM_BIN_PATH" + elif [ -n "${XDG_BIN_HOME:-}" ]; then + _bin_dir="$XDG_BIN_HOME" + elif [ -n "${XDG_DATA_HOME:-}" ]; then + _bin_dir="$(dirname "$XDG_DATA_HOME")/bin" + else + _bin_dir="$(get_home)/.local/bin" + fi +} + +cleanup() { + if [ -n "${_tmp_dir:-}" ]; then + rm -rf "$_tmp_dir" + fi + if [ -n "${_staged:-}" ] && [ -f "$_staged" ]; then + rm -f "$_staged" + fi +} + +main() { + _tag='' + _bin_path='' + _install_completion=1 + if [ "${SEAM_NO_COMPLETIONS:-0}" = 1 ]; then + _install_completion=0 + fi + + while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || err "--version requires a value, e.g. --version v0.29.0" + _tag="$2" + shift 2 + ;; + --bin-path) + [ $# -ge 2 ] || err "--bin-path requires a value, e.g. --bin-path ~/.local/bin" + _bin_path="$2" + shift 2 + ;; + --no-install-completion) + _install_completion=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + -*) + err "unknown option: $1 (try --help)" + ;; + *) + [ -z "$_tag" ] || err "unexpected argument: $1 (try --help)" + _tag="$1" + shift + ;; + esac + done + + need_cmd uname + need_cmd mktemp + need_cmd mkdir + need_cmd chmod + need_cmd rm + need_cmd grep + need_cmd awk + need_cmd cut + detect_downloader + + detect_platform + + if [ -z "$_tag" ]; then + resolve_latest_tag + else + case "$_tag" in + v*) ;; + *) _tag="v${_tag}" ;; + esac + fi + + _asset="${APP_NAME}-${_tag}-${_os}-${_arch}" + _asset_url="${DOWNLOAD_BASE}/${_tag}/${_asset}" + _checksums_url="${DOWNLOAD_BASE}/${_tag}/checksums.txt" + + resolve_bin_dir + + _tmp_dir=$(mktemp -d) || err "cannot create a temporary directory" + trap cleanup EXIT HUP INT TERM + + say "downloading ${_asset_url}" + download "$_asset_url" "${_tmp_dir}/${_asset}" || + err "cannot download ${_asset_url} +Check that release ${_tag} exists and has a binary for ${_os}-${_arch}: + https://github.com/${REPO}/releases" + + say "verifying the checksum with ${_checksums_url}" + download "$_checksums_url" "${_tmp_dir}/checksums.txt" || + err "cannot download ${_checksums_url}" + + _expected=$(awk -v asset="$_asset" \ + '{sum = $1; file = $2; sub(/^\*/, "", file)} file == asset {print sum}' \ + "${_tmp_dir}/checksums.txt") + [ -n "$_expected" ] || err "${_asset} is not listed in checksums.txt: refusing to install" + _actual=$(sha256 "${_tmp_dir}/${_asset}") + if [ "$_actual" != "$_expected" ]; then + err "checksum mismatch for ${_asset}: refusing to install + expected: ${_expected} + actual: ${_actual}" + fi + + mkdir -p "$_bin_dir" || err "cannot create ${_bin_dir}" + # Stage next to the final path so the last step is an atomic rename and a + # partially written binary is never installed. + _staged="${_bin_dir}/.${APP_NAME}.tmp.$$" + cp "${_tmp_dir}/${_asset}" "$_staged" || err "cannot write to ${_bin_dir}" + chmod +x "$_staged" + mv -f "$_staged" "${_bin_dir}/${APP_NAME}" + say "installed ${_bin_dir}/${APP_NAME} ($("${_bin_dir}/${APP_NAME}" --version 2> /dev/null || echo "$_tag"))" + + case ":${PATH}:" in + *":${_bin_dir}:"*) ;; + *) + warn "${_bin_dir} is not on your PATH +Add it to your shell profile, for example: + export PATH=\"${_bin_dir}:\$PATH\"" + ;; + esac + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$_bin_dir" >> "$GITHUB_PATH" + fi + + if [ "$_install_completion" = 1 ]; then + if PATH="${_bin_dir}:${PATH}" "${_bin_dir}/${APP_NAME}" completion --install; then + : + else + warn "cannot install shell completions +Install them later with: + ${APP_NAME} completion --install [bash|fish|zsh]" + fi + fi +} + +main "$@" From af185e14e8ec6ef202975a710c1b923ed97d79b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:10:19 +0000 Subject: [PATCH 2/4] Rename install-script workflow to install-standalone Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtYdE6UGg6ZR7ieqS5YpHt --- .../{install-script.yml => install-standalone.yml} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename .github/workflows/{install-script.yml => install-standalone.yml} (97%) diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-standalone.yml similarity index 97% rename from .github/workflows/install-script.yml rename to .github/workflows/install-standalone.yml index 8619d2d1..9915f0a7 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-standalone.yml @@ -1,5 +1,5 @@ --- -name: Install script +name: Install standalone on: push: @@ -8,14 +8,14 @@ on: paths: - install.sh - install.ps1 - - .github/workflows/install-script.yml + - .github/workflows/install-standalone.yml pull_request: branches: - '**' paths: - install.sh - install.ps1 - - .github/workflows/install-script.yml + - .github/workflows/install-standalone.yml jobs: lint: From 1a92cd864c8374a11df0a0da48c04df55e52c229 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:11:48 +0000 Subject: [PATCH 3/4] Rename install workflow jobs to sh and powershell Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtYdE6UGg6ZR7ieqS5YpHt --- .github/workflows/install-standalone.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/install-standalone.yml b/.github/workflows/install-standalone.yml index 9915f0a7..3c66abc8 100644 --- a/.github/workflows/install-standalone.yml +++ b/.github/workflows/install-standalone.yml @@ -27,8 +27,8 @@ jobs: uses: actions/checkout@v7 - name: Shellcheck run: shellcheck install.sh - test: - name: Test (${{ matrix.os_name }}) + sh: + name: Sh (${{ matrix.os_name }}) runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -88,8 +88,8 @@ jobs: echo 'Nothing must be installed on a checksum mismatch' >&2 exit 1 fi - test_windows: - name: Test (Windows) + powershell: + name: PowerShell (Windows) runs-on: windows-latest timeout-minutes: 30 steps: From 85a30ce52345bdf4ff9a948189e40e14571b28a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:20:28 +0000 Subject: [PATCH 4/4] Add the install directory to PATH in the shell configuration When the install directory is not on PATH, install.sh now appends the PATH entry to the user's shell configuration instead of only printing an instruction, detecting bash, fish, or zsh and picking the configuration file the same way as seam completion --install. The edit is idempotent. When the shell cannot be detected, or with --no-modify-path (or SEAM_NO_MODIFY_PATH=1), the script prints the manual instruction instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtYdE6UGg6ZR7ieqS5YpHt --- .github/workflows/install-standalone.yml | 8 +- README.md | 9 +- install.sh | 118 +++++++++++++++++++++-- 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/.github/workflows/install-standalone.yml b/.github/workflows/install-standalone.yml index 3c66abc8..c4b5e725 100644 --- a/.github/workflows/install-standalone.yml +++ b/.github/workflows/install-standalone.yml @@ -58,8 +58,14 @@ jobs: "$RUNNER_TEMP/seam-bin/seam" --version | grep -F 0.29.0 - name: Install to XDG_BIN_HOME run: | - XDG_BIN_HOME="$RUNNER_TEMP/xdg-bin" SEAM_NO_COMPLETIONS=1 sh install.sh + XDG_BIN_HOME="$RUNNER_TEMP/xdg-bin" SEAM_NO_COMPLETIONS=1 \ + SEAM_NO_MODIFY_PATH=1 sh install.sh "$RUNNER_TEMP/xdg-bin/seam" --version + - name: Check modified PATH + if: ${{ runner.os == 'Linux' }} + run: | + grep -xF "export PATH=\"$RUNNER_TEMP/seam-bin:\$PATH\"" "$HOME/.bashrc" + ! grep -F "$RUNNER_TEMP/xdg-bin" "$HOME/.bashrc" - name: Refuse to install on checksum mismatch run: | os=$(uname -s | tr '[:upper:]' '[:lower:]') diff --git a/README.md b/README.md index 5e6222a9..3fe09abd 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,12 @@ The script downloads the binary for your platform, verifies its SHA-256 checksum against the release's `checksums.txt` before installing, and runs `seam completion --install`. It installs to the first of `$SEAM_BIN_PATH`, `$XDG_BIN_HOME`, `$XDG_DATA_HOME/../bin`, or `~/.local/bin` -(`%LOCALAPPDATA%\seam\bin` on Windows). Pass `--version ` to pin a -version, `--bin-path ` to choose the install directory, and -`--no-install-completion` to leave your shell configuration untouched, e.g. +(`%LOCALAPPDATA%\seam\bin` on Windows). If the install directory is not on +your `PATH`, the script adds it to your shell configuration, detecting bash, +fish, or zsh the same way as `seam completion --install`. Pass +`--version ` to pin a version, `--bin-path ` to choose the install +directory, and `--no-modify-path` or `--no-install-completion` to leave your +shell configuration untouched, e.g. ``` $ curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh \ diff --git a/install.sh b/install.sh index d18d0095..15ff308c 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,8 @@ # # Downloads the standalone seam binary for this platform from GitHub Releases, # verifies its SHA-256 checksum against the release's checksums.txt in a -# temporary directory, installs it, and installs shell completions. +# temporary directory, installs it, adds the install directory to PATH in the +# shell configuration when needed, and installs shell completions. # # Usage: # curl -fsSL https://raw.githubusercontent.com/seamapi/cli/main/install.sh | sh @@ -16,12 +17,16 @@ # XDG_BIN_HOME, then XDG_DATA_HOME/../bin, then # ~/.local/bin). # --no-install-completion Do not run `seam completion --install`. +# --no-modify-path Do not add the install directory to PATH in the +# shell configuration. # -h, --help Show this help. # # Environment: # SEAM_BIN_PATH Install directory (same as --bin-path). # SEAM_NO_COMPLETIONS Set to 1 to skip completions (same as # --no-install-completion). +# SEAM_NO_MODIFY_PATH Set to 1 to never edit the shell configuration for +# PATH (same as --no-modify-path). # SEAM_DOWNLOAD_URL Base URL for release downloads, for mirrors and # testing (default: # https://github.com/seamapi/cli/releases/download). @@ -48,11 +53,14 @@ Options: XDG_BIN_HOME, then XDG_DATA_HOME/../bin, then ~/.local/bin). --no-install-completion Do not run 'seam completion --install'. + --no-modify-path Do not add the install directory to PATH in the + shell configuration. -h, --help Show this help. Environment: SEAM_BIN_PATH Install directory (same as --bin-path). SEAM_NO_COMPLETIONS Set to 1 to skip completions. + SEAM_NO_MODIFY_PATH Set to 1 to never edit the shell configuration for PATH. SEAM_DOWNLOAD_URL Base URL for release downloads (mirrors and testing). EOF } @@ -186,6 +194,100 @@ resolve_bin_dir() { fi } +# shell_name '-/bin/zsh' -> 'zsh' +shell_name() { + [ -n "$1" ] || return 0 + basename "$1" | sed 's/^-//' +} + +is_shell() { + case "$1" in + bash | zsh | fish) return 0 ;; + *) return 1 ;; + esac +} + +# Detect the user's shell the same way 'seam completion --install' does +# (src/lib/completion/detect-shell.ts): walk up to 10 process ancestors, +# preferring procfs over ps, then fall back to SHELL. Sets _shell. +detect_shell() { + _shell='' + _pid="${PPID:-0}" + _depth=0 + while [ "$_depth" -lt 10 ] && [ "$_pid" -gt 1 ] 2> /dev/null; do + if [ -r "/proc/${_pid}/comm" ]; then + _comm=$(cat "/proc/${_pid}/comm" 2> /dev/null) || _comm='' + # In /proc//stat the ppid is the second field after the last ')'. + _ppid=$(sed 's/^.*) *//' "/proc/${_pid}/stat" 2> /dev/null | awk '{print $2}') + elif check_cmd ps; then + _psout=$(ps -p "$_pid" -o comm=,ppid= 2> /dev/null) || _psout='' + [ -n "$_psout" ] || break + _ppid=$(echo "$_psout" | awk '{print $NF}') + _comm=$(echo "$_psout" | awk '{$NF = ""; sub(/ +$/, ""); print}') + else + break + fi + [ -n "$_comm" ] || break + _name=$(shell_name "$_comm") + if is_shell "$_name"; then + _shell="$_name" + return 0 + fi + _pid="$_ppid" + _depth=$((_depth + 1)) + done + _name=$(shell_name "${SHELL:-}") + if is_shell "$_name"; then + _shell="$_name" + return 0 + fi + return 1 +} + +# Pick the configuration file for _shell the same way +# 'seam completion --install' does (src/lib/completion/install.ts). +# Sets _shell_config and _path_line. +resolve_shell_config() { + case "$_shell" in + fish) + _shell_config="${XDG_CONFIG_HOME:-$(get_home)/.config}/fish/config.fish" + _path_line="fish_add_path \"${_bin_dir}\"" + ;; + zsh) + _shell_config="${ZDOTDIR:-$(get_home)}/.zshrc" + _path_line="export PATH=\"${_bin_dir}:\$PATH\"" + ;; + bash) + _home=$(get_home) + if [ -f "${_home}/.bashrc" ]; then + _shell_config="${_home}/.bashrc" + elif [ -f "${_home}/.bash_profile" ]; then + _shell_config="${_home}/.bash_profile" + else + _shell_config="${_home}/.bashrc" + fi + _path_line="export PATH=\"${_bin_dir}:\$PATH\"" + ;; + esac +} + +modify_path() { + if [ "$_modify_path" = 1 ] && detect_shell; then + resolve_shell_config + mkdir -p "$(dirname "$_shell_config")" + if [ -f "$_shell_config" ] && grep -qxF "$_path_line" "$_shell_config"; then + say "${_bin_dir} is already added to PATH in ${_shell_config}" + else + printf '%s\n' "$_path_line" >> "$_shell_config" + say "added ${_bin_dir} to PATH in ${_shell_config}" + fi + say "open a new shell to use ${APP_NAME}, or run 'exec ${_shell}' now" + else + warn "make sure ${_bin_dir} is added to your PATH, for example: + export PATH=\"${_bin_dir}:\$PATH\"" + fi +} + cleanup() { if [ -n "${_tmp_dir:-}" ]; then rm -rf "$_tmp_dir" @@ -202,6 +304,10 @@ main() { if [ "${SEAM_NO_COMPLETIONS:-0}" = 1 ]; then _install_completion=0 fi + _modify_path=1 + if [ "${SEAM_NO_MODIFY_PATH:-0}" = 1 ]; then + _modify_path=0 + fi while [ $# -gt 0 ]; do case "$1" in @@ -219,6 +325,10 @@ main() { _install_completion=0 shift ;; + --no-modify-path) + _modify_path=0 + shift + ;; -h | --help) usage exit 0 @@ -296,11 +406,7 @@ Check that release ${_tag} exists and has a binary for ${_os}-${_arch}: case ":${PATH}:" in *":${_bin_dir}:"*) ;; - *) - warn "${_bin_dir} is not on your PATH -Add it to your shell profile, for example: - export PATH=\"${_bin_dir}:\$PATH\"" - ;; + *) modify_path ;; esac if [ -n "${GITHUB_PATH:-}" ]; then echo "$_bin_dir" >> "$GITHUB_PATH"