#!/usr/bin/env bash # # Crewship installer # # curl -fsSL https://crewship.ai/install | bash # # (Pipe to `bash`, not `sh`. The script uses `set -euo pipefail` and # array expansion guards that POSIX sh does not implement; piping to # plain `sh` on Debian/Ubuntu — where /bin/sh is dash — produces an # obscure "Bad substitution" failure halfway through. The shebang # above protects direct execution; the one-liner needs the same.) # # Detects OS+arch, downloads the matching release archive from GitHub, # verifies the SHA-256 checksum against the signed checksums.txt, and # installs the binary into a writable directory on PATH. # # When `cosign` is available locally the script ALSO verifies the # release's keyless signature against the project's expected OIDC # identity (GitHub Actions workflow). Missing cosign is a soft warning # rather than a hard failure — keeping the install one-liner workable # for users who haven't installed sigstore tooling yet. # # Environment overrides: # CREWSHIP_VERSION pin a specific tag (default: latest stable) # CREWSHIP_INSTALL_DIR override install directory # CREWSHIP_SKIP_VERIFY set to 1 to skip checksum verification (NOT recommended) set -euo pipefail REPO="crewship-ai/crewship" EXPECTED_OIDC_ISSUER="https://token.actions.githubusercontent.com" # Literal-dot escapes matter: with unescaped `.` an attacker who got a # different cosign identity to claim e.g. "githubXcom/crewship-ai/..." # would slip past the regex. The `.+` tail is intentional — cosign # matches the full URL including the ref suffix (`@refs/tags/vX.Y.Z`). EXPECTED_CERT_IDENTITY_RE="https://github\.com/${REPO}/\.github/workflows/release\.yml@.*" # ---------- colors ---------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_BOLD='\033[1m' C_DIM='\033[2m' C_GREEN='\033[32m' C_YELLOW='\033[33m' C_RED='\033[31m' C_RESET='\033[0m' else C_BOLD='' C_DIM='' C_GREEN='' C_YELLOW='' C_RED='' C_RESET='' fi info() { printf '%b\n' "${C_DIM}==>${C_RESET} $*"; } ok() { printf '%b\n' "${C_GREEN}✓${C_RESET} $*"; } warn() { printf '%b\n' "${C_YELLOW}!${C_RESET} $*" >&2; } err() { printf '%b\n' "${C_RED}✗${C_RESET} $*" >&2; exit 1; } # ---------- platform detection ---------- detect_os() { case "$(uname -s)" in Linux) echo linux ;; Darwin) echo darwin ;; MINGW*|MSYS*|CYGWIN*) err "Windows is not supported by this script. Download the .zip from https://github.com/${REPO}/releases or install via 'scoop install crewship'." ;; *) err "unsupported OS: $(uname -s)" ;; esac } detect_arch() { case "$(uname -m)" in x86_64|amd64) echo amd64 ;; arm64|aarch64) echo arm64 ;; *) err "unsupported arch: $(uname -m)" ;; esac } # ---------- prerequisite check ---------- require() { command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" } require curl require tar require uname # ---------- resolve version ---------- resolve_version() { if [ -n "${CREWSHIP_VERSION:-}" ]; then echo "$CREWSHIP_VERSION" return fi # GitHub `releases/latest` returns the most recent NON-prerelease, which # is exactly what a curl-pipe-install should default to. Beta testers who # want a pre-release pin via CREWSHIP_VERSION=v0.1.0-beta.1. api_url="https://api.github.com/repos/${REPO}/releases/latest" tag=$(curl -fsSL "$api_url" 2>/dev/null \ | grep -E '"tag_name"' \ | head -n1 \ | sed -E 's/.*"tag_name"\s*:\s*"([^"]+)".*/\1/') if [ -z "$tag" ]; then err "could not resolve latest release. Set CREWSHIP_VERSION=vX.Y.Z to install a specific tag." fi echo "$tag" } # ---------- install directory ---------- # Order of preference: # 1. $CREWSHIP_INSTALL_DIR (operator override) # 2. $XDG_BIN_HOME, $HOME/.local/bin if on PATH or creatable (no sudo) # 3. /usr/local/bin (sudo if not writable) pick_install_dir() { if [ -n "${CREWSHIP_INSTALL_DIR:-}" ]; then mkdir -p "$CREWSHIP_INSTALL_DIR" echo "$CREWSHIP_INSTALL_DIR" return fi for candidate in "${XDG_BIN_HOME:-}" "$HOME/.local/bin"; do [ -z "$candidate" ] && continue if mkdir -p "$candidate" 2>/dev/null && [ -w "$candidate" ]; then case ":$PATH:" in *":$candidate:"*) echo "$candidate"; return ;; esac fi done echo /usr/local/bin } # ---------- download + verify ---------- fetch_to() { url=$1 dest=$2 curl -fsSL --retry 3 -o "$dest" "$url" \ || err "download failed: $url" } verify_checksum() { archive=$1 checksums=$2 # checksums.txt format: " " archive_basename=$(basename "$archive") expected=$(grep -E " ${archive_basename}$" "$checksums" | awk '{print $1}' | head -n1) if [ -z "$expected" ]; then err "checksum for ${archive_basename} not found in checksums.txt" fi if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$archive" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$archive" | awk '{print $1}') else err "no sha256sum or shasum available" fi if [ "$actual" != "$expected" ]; then err "checksum mismatch for ${archive_basename}\n expected: $expected\n actual: $actual" fi ok "checksum verified" } # Cosign keyless verification: confirms the artifact was signed by the # project's own release workflow running under GitHub Actions. The cert # identity regex pins the verification to crewship-ai/crewship's # release.yml — a forked or impersonated workflow won't pass even if # the attacker controls a GitHub Actions runner. verify_cosign() { archive=$1 sig=$2 cert=$3 if ! command -v cosign >/dev/null 2>&1; then warn "cosign not installed — skipping signature verification (install: https://docs.sigstore.dev/cosign/installation/)" return fi cosign verify-blob \ --certificate-identity-regexp "$EXPECTED_CERT_IDENTITY_RE" \ --certificate-oidc-issuer "$EXPECTED_OIDC_ISSUER" \ --signature "$sig" \ --certificate "$cert" \ "$archive" >/dev/null 2>&1 \ || err "cosign signature verification failed for $(basename "$archive")" ok "cosign signature verified" } # ---------- install ---------- install_binary() { src=$1 dst_dir=$2 dst="${dst_dir}/crewship" if [ -w "$dst_dir" ]; then mv "$src" "$dst" chmod +x "$dst" ok "installed: $dst" return fi # Need elevated permissions. Three cases: # (a) already root → mv/chmod directly, no sudo needed # (b) sudo present → use it, prompt the user once # (c) neither → hard fail with a clear remediation hint # The previous version blindly called `sudo` and produced # "sudo: command not found" in case (c) — common in minimal Docker # images (Alpine, distroless build stages, BusyBox-only systems). if [ "$(id -u)" = "0" ]; then mv "$src" "$dst" chmod +x "$dst" elif command -v sudo >/dev/null 2>&1; then info "installing to $dst (sudo required)" sudo mv "$src" "$dst" sudo chmod +x "$dst" else err "cannot write to $dst_dir and sudo is not available. Either run this script as root, install sudo, or set CREWSHIP_INSTALL_DIR to a directory you own (e.g. \$HOME/.local/bin) and re-run." fi ok "installed: $dst" } # ---------- post-install hints ---------- check_docker_hint() { if command -v docker >/dev/null 2>&1; then return fi if command -v podman >/dev/null 2>&1; then return fi cat >&2 <__.tar.gz where is e.g. 0.1.0. VERSION=${TAG#v} info "platform: ${OS}/${ARCH}" info "version: ${TAG}" ARCHIVE="crewship_${VERSION}_${OS}_${ARCH}.tar.gz" BASE_URL="https://github.com/${REPO}/releases/download/${TAG}" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT info "downloading ${ARCHIVE}" fetch_to "${BASE_URL}/${ARCHIVE}" "${TMP}/${ARCHIVE}" fetch_to "${BASE_URL}/checksums.txt" "${TMP}/checksums.txt" if [ "${CREWSHIP_SKIP_VERIFY:-0}" = "1" ]; then warn "skipping checksum verification (CREWSHIP_SKIP_VERIFY=1)" else verify_checksum "${TMP}/${ARCHIVE}" "${TMP}/checksums.txt" # Cosign artifacts are best-effort: if a release predates the signing # workflow they won't exist, in which case we skip silently. if curl -fsSL --retry 2 -o "${TMP}/${ARCHIVE}.sig" "${BASE_URL}/${ARCHIVE}.sig" 2>/dev/null \ && curl -fsSL --retry 2 -o "${TMP}/${ARCHIVE}.pem" "${BASE_URL}/${ARCHIVE}.pem" 2>/dev/null; then verify_cosign "${TMP}/${ARCHIVE}" "${TMP}/${ARCHIVE}.sig" "${TMP}/${ARCHIVE}.pem" else info "no cosign signatures published for this release — skipping" fi fi info "extracting" tar -xzf "${TMP}/${ARCHIVE}" -C "${TMP}" if [ ! -f "${TMP}/crewship" ]; then err "archive did not contain expected 'crewship' binary" fi DEST=$(pick_install_dir) install_binary "${TMP}/crewship" "$DEST" # Sanity check — the binary we just installed must run. if ! "${DEST}/crewship" version >/dev/null 2>&1; then err "installed binary failed to execute. PATH=${PATH}" fi printf '\n' # Capture-then-extract rather than piping through `head -n1`. Under # `set -euo pipefail` the pipeline can SIGPIPE if head closes its # input before crewship has finished writing, which surfaces as a # spurious install failure exit. Capturing into a variable first # decouples the two. version_full=$("${DEST}/crewship" version) version_line=${version_full%%$'\n'*} ok "$version_line" check_docker_hint # PATH hint if we installed somewhere not on PATH (rare given pick_install_dir). case ":$PATH:" in *":${DEST}:"*) ;; *) warn "${DEST} is not on your PATH. Add this to your shell rc:" printf ' export PATH="%s:$PATH"\n' "$DEST" ;; esac printf '\nNext: %brun%b\n' "${C_BOLD}" "${C_RESET}" printf ' crewship start\n\n' } main "$@"