#!/usr/bin/env bash # Smithers CLI installer # Requires Cosign v3 or later, installed from a trusted package manager. # # Install latest: # curl -fsSL https://get.jjhub.tech/cli | bash # # Install specific version: # curl -fsSL https://get.jjhub.tech/cli | SMITHERS_VERSION=v1.0.0 bash # # Install to custom directory: # curl -fsSL https://get.jjhub.tech/cli | SMITHERS_INSTALL_DIR=~/.local/bin bash # set -euo pipefail BINARY="smithers" DEFAULT_RELEASE_BASE_URL="https://releases.jjhub.tech" RELEASE_BASE_URL="${SMITHERS_RELEASE_BASE_URL:-$DEFAULT_RELEASE_BASE_URL}" LATEST_VERSION_URL="${SMITHERS_LATEST_VERSION_URL:-${RELEASE_BASE_URL%/}/latest.txt}" INSTALL_DIR="${SMITHERS_INSTALL_DIR:-${HOME}/.local/bin}" detect_os() { case "$(uname -s)" in Linux*) echo "linux" ;; Darwin*) echo "darwin" ;; *) echo "unsupported" ;; esac } detect_arch() { case "$(uname -m)" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; *) echo "unsupported" ;; esac } download() { local url="$1" dest="$2" if command -v curl > /dev/null 2>&1; then curl -fsSL -o "$dest" "$url" elif command -v wget > /dev/null 2>&1; then wget -qO "$dest" "$url" else echo "Error: curl or wget is required" >&2 exit 1 fi } latest_version() { download "$LATEST_VERSION_URL" "${TMPDIR_CLEANUP}/latest.txt" cat "${TMPDIR_CLEANUP}/latest.txt" } verify_checksum() { local archive_path="$1" sums_path="$2" archive="$3" local selected_sums selected_sums="$(dirname "$archive_path")/SHA256SUMS.selected" if ! awk -v archive="$archive" '$2 == archive { print; found = 1 } END { exit found ? 0 : 1 }' "$sums_path" > "$selected_sums"; then echo "Error: checksum for ${archive} was not found in SHA256SUMS" >&2 exit 1 fi if command -v sha256sum > /dev/null 2>&1; then (cd "$(dirname "$archive_path")" && sha256sum -c "$(basename "$selected_sums")") elif command -v shasum > /dev/null 2>&1; then (cd "$(dirname "$archive_path")" && shasum -a 256 -c "$(basename "$selected_sums")") else echo "Error: sha256sum or shasum is required to verify the download" >&2 exit 1 fi } verify_signature() { local sums_path="$1" bundle_path="$2" version="$3" # Trust the upstream release job at this exact tag, never the download host. # Cosign verifies the certificate chain, signature, and transparency evidence. if ! cosign verify-blob "$sums_path" \ --bundle "$bundle_path" \ --certificate-identity "https://github.com/smithersai/smithers/.github/workflows/release.yml@refs/tags/${version}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com"; then echo "Error: release signature verification failed; refusing to install" >&2 exit 1 fi } TMPDIR_CLEANUP="" STAGING_DIR="" cleanup() { if [ -n "$TMPDIR_CLEANUP" ]; then rm -rf "$TMPDIR_CLEANUP"; fi # A signal can arrive between the atomic rename and clearing STAGING_DIR. # Once next has moved, the complete payload may be in use: retain it. if [ -n "$STAGING_DIR" ] && { [ "$ACTIVATING" = 0 ] || [ -L "${STAGING_DIR}/next" ]; }; then rm -rf "$STAGING_DIR" fi } ACTIVATING=0 trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM validate_layout() { local directory="$1" path for path in runtime node_modules node_modules/@smthrs node_modules/@smthrs/cli node_modules/@smthrs/cli/bin; do if [ -L "$directory/$path" ] || [ ! -d "$directory/$path" ]; then echo "Error: archive is missing regular directory ${path}" >&2 exit 1 fi done for path in smithers runtime/node node_modules/@smthrs/cli/bin/smithers.mjs; do if [ -L "$directory/$path" ] || [ ! -f "$directory/$path" ]; then echo "Error: archive is missing regular file ${path}" >&2 exit 1 fi done if [ ! -x "$directory/smithers" ] || [ ! -x "$directory/runtime/node" ]; then echo "Error: archive launcher and bundled runtime must be executable" >&2 exit 1 fi } main() { local os arch version archive release_url checksum_url os="$(detect_os)" arch="$(detect_arch)" if [ "$os" = "unsupported" ]; then echo "Error: unsupported operating system: $(uname -s)" >&2 echo "Smithers supports Linux and macOS." >&2 exit 1 fi if [ "$arch" = "unsupported" ]; then echo "Error: unsupported architecture: $(uname -m)" >&2 echo "Smithers supports x86_64 (amd64) and aarch64 (arm64)." >&2 exit 1 fi umask 077 TMPDIR_CLEANUP="$(mktemp -d)" version="${SMITHERS_VERSION:-$(latest_version)}" # Anchored at both ends: the version becomes part of the download URL. if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "Error: invalid Smithers version '${version}'." >&2 echo "Set SMITHERS_VERSION=v0.1.0 to install a specific version." >&2 exit 1 fi if ! command -v cosign > /dev/null 2>&1; then echo "Error: Cosign v3 or later is required to verify release signatures." >&2 echo "Install it from a trusted package manager: https://docs.sigstore.dev/cosign/system_config/installation/" >&2 exit 1 fi archive="${BINARY}-${version}-${os}-${arch}.tar.gz" release_url="${RELEASE_BASE_URL%/}/${version}/${archive}" checksum_url="${RELEASE_BASE_URL%/}/${version}/SHA256SUMS" echo "Installing ${BINARY} ${version} (${os}/${arch})..." local tmpdir="$TMPDIR_CLEANUP" echo "Downloading ${release_url}..." download "$checksum_url" "${tmpdir}/SHA256SUMS" download "${checksum_url}.sigstore.json" "${tmpdir}/SHA256SUMS.sigstore.json" echo "Verifying release signature..." verify_signature "${tmpdir}/SHA256SUMS" "${tmpdir}/SHA256SUMS.sigstore.json" "$version" download "$release_url" "${tmpdir}/${archive}" echo "Verifying checksum..." verify_checksum "${tmpdir}/${archive}" "${tmpdir}/SHA256SUMS" "$archive" # Stage on the destination filesystem so activation is one rename. The # random parent remains private while tar restores the whole npm directory. mkdir -p "$INSTALL_DIR" INSTALL_DIR="$(CDPATH='' cd -- "$INSTALL_DIR" && pwd -P)" if [ ! -w "$INSTALL_DIR" ] || [ -d "${INSTALL_DIR}/${BINARY}" ]; then echo "Error: install directory must be writable and ${BINARY} must not be a directory" >&2 exit 1 fi STAGING_DIR="$(mktemp -d "${INSTALL_DIR}/.smithers-${version}.XXXXXX")" mkdir "${STAGING_DIR}/payload" echo "Extracting..." (umask 022; tar xzf "${tmpdir}/${archive}" --no-same-owner --no-same-permissions -C "${STAGING_DIR}/payload") validate_layout "${STAGING_DIR}/payload" # Smoke the relocated, authenticated payload before touching the active # installation. The archive already includes Node and all npm dependencies. local installed_version installed_version="$("${STAGING_DIR}/payload/${BINARY}" --version)" if [ "$installed_version" != "${version#v}" ]; then echo "Error: installed CLI version does not match ${version}" >&2 exit 1 fi echo "Installing to ${INSTALL_DIR}/${BINARY}..." chmod 755 "$STAGING_DIR" "${STAGING_DIR}/payload" ln -s "${STAGING_DIR}/payload/${BINARY}" "${STAGING_DIR}/next" # Never follow an existing destination symlink (including on BSD/macOS). # Retain previous payloads so processes already running can finish safely. ACTIVATING=1 if [ "$os" = "darwin" ]; then mv -fh "${STAGING_DIR}/next" "${INSTALL_DIR}/${BINARY}" else mv -fT "${STAGING_DIR}/next" "${INSTALL_DIR}/${BINARY}" fi STAGING_DIR="" echo "" echo "Smithers CLI ${version} installed to ${INSTALL_DIR}/${BINARY}" echo "" echo "Get started:" echo " smithers --help" echo "" echo "Installed: ${installed_version}" if ! command -v smithers > /dev/null 2>&1; then echo "Note: ${INSTALL_DIR} may not be in your PATH." echo "Add it with: export PATH=\"${INSTALL_DIR}:\$PATH\"" fi } main "$@"