#!/usr/bin/env bash # The public bundle fills release_repository and signing_key. With no version # it installs the newest stable release. The team bundle also fills version. set -euo pipefail version='' release_repository='portablcorp/cosyra-releases' # Where a private install (gh, no public release repository) downloads from. private_repository='portablcorp/portabl-backend' # The key releases are signed with, in the form ssh-keygen reads. The public # bundle fills this, and a download must then prove itself with the release's # signed manifest. A private build has neither and uses its checksum file. signing_key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINZZLTiGvY/7kCAzvV10b7OxFY7GYpfF/wFNgggxDebS' version_pattern='^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$' usage() { cat <<'HELP' Usage: bash install.sh [--version VERSION] [--dir DIRECTORY] [--from DIRECTORY | --base-url HTTPS_URL] [--no-modify-path] --from installs a previously downloaded archive with its release's manifest.json and manifest.json.sshsig (a private build: with its checksum file instead). --base-url downloads those files directly from an HTTPS directory using curl. The default install directory is ~/.local/bin. If needed, it is added to PATH in your zsh or Bash startup files. Use --no-modify-path to skip shell changes. An existing cosyra file is replaced once the new one passes its checks. The installer only installs. It ends with the command to run next, such as cosyra remote start to turn on remote control. HELP if [ -n "$release_repository" ]; then printf 'Default: the newest stable release from %s. No GitHub sign-in required.\n' "$release_repository" else printf 'Private downloads require --version VERSION and gh authentication.\n' fi } fail() { printf 'Error: %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"; } # ssh-keygen is the one stock tool with Ed25519 on every Mac: macOS's openssl is # LibreSSL without it, and python3 and swift need the developer tools. need_signature_tool() { command -v ssh-keygen >/dev/null 2>&1 || fail 'ssh-keygen is needed to check the signature of the download. On Debian or Ubuntu: sudo apt-get install openssh-client' local help help=$(ssh-keygen -\? 2>&1 "$temporary/allowed_signers" ssh-keygen -Y verify -f "$temporary/allowed_signers" -I cosyra -n cosyra-manifest \ -s "$temporary/manifest.json.sshsig" < "$temporary/manifest.json" >/dev/null 2>&1 || fail 'Signature check failed: this download was not signed by Cosyra. Nothing was installed. Please tell us at hello@cosyra.com.' manifest=$(<"$temporary/manifest.json") [[ "$manifest" == *"\"version\": \"$version\","* ]] || fail "The signed manifest is not for version $version. Nothing was installed." pattern='"archive": "'"$asset"'",[[:space:]]+"sha256": "([a-f0-9]{64})"' [[ "$manifest" =~ $pattern ]] || fail "The signed manifest does not list $asset. Nothing was installed." [ "$(archive_sha256)" = "${BASH_REMATCH[1]}" ] || fail 'Signature check failed: this download is not the file Cosyra signed. Nothing was installed. Please tell us at hello@cosyra.com.' } append_path_entry() { local startup_file="$1" entry="$2" if [ -f "$startup_file" ] && grep -Fqx -- "$entry" "$startup_file"; then return 0 fi mkdir -p -- "${startup_file%/*}" && printf '\n# Cosyra CLI\n%s\n' "$entry" >> "$startup_file" } configure_path() { case ":${PATH:-}:" in *:"$install_dir":*) # Already on PATH, but a cosyra earlier on it answers to the name. [ "$(type -P cosyra || true)" = "$target" ] || by_name=false return ;; esac if [ "$modify_path" != true ]; then printf 'PATH setup skipped (--no-modify-path).\n' by_name=false return fi # Colons cannot be represented inside a PATH entry. Avoid control characters # in startup files; the absolute executable remains usable in either case. if [[ "$install_dir" == *:* || "$install_dir" == *[[:cntrl:]]* ]]; then printf 'Cannot add this installation directory to PATH; use the full path below.\n' >&2 by_name=false return fi local startup_files=() profile startup_file quoted_dir entry configured=true case "${SHELL:-}" in */zsh|zsh) startup_files=("${ZDOTDIR:-$HOME}/.zshrc") ;; */bash|bash) startup_files=("$HOME/.bashrc") profile="$HOME/.profile" for startup_file in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do if [ -f "$startup_file" ] && [ -r "$startup_file" ]; then profile="$startup_file" break fi done startup_files+=("$profile") ;; *) printf 'Automatic PATH setup supports zsh and Bash. Use the full path below for this shell.\n' by_name=false return ;; esac # Single quotes keep the directory literal in both Bash and zsh patterns, # including zsh EXTENDED_GLOB. Escape embedded single quotes outside them. local single_quote_escape="'\\''" quoted_dir="'${install_dir//\'/$single_quote_escape}'" entry="case \":\${PATH:-}:\" in *:${quoted_dir}:*) ;; *) export PATH=${quoted_dir}:\"\${PATH:-}\" ;; esac" for startup_file in "${startup_files[@]}"; do if append_path_entry "$startup_file" "$entry"; then printf 'Configured PATH in %s\n' "$startup_file" else printf 'Could not update %s; set PATH manually.\n' "$startup_file" >&2 configured=false fi done if [ "$configured" = true ]; then printf 'Open a new terminal to use cosyra, or run this in your current terminal:\n' else printf 'To use cosyra in your current terminal, run:\n' by_name=false fi printf ' export PATH=%s:"$PATH"\n' "$quoted_dir" } install_dir="${COSYRA_INSTALL_DIR:-${HOME:?HOME must be set}/.local/bin}" source_dir='' base_url='' modify_path=true # by_name is whether a terminal finds cosyra on PATH, now or once it is opened # again; configure_path clears it when only the full path works. by_name=true while [ "$#" -gt 0 ]; do case "$1" in --version|--dir|--from|--base-url) [ "$#" -ge 2 ] && [ -n "$2" ] || fail "$1 requires a value" case "$1" in --version) version="$2" ;; --dir) install_dir="$2" ;; --from) source_dir="$2" ;; --base-url) base_url="$2" ;; esac shift 2 ;; # Replacing is the default. Older instructions and scripts still pass this. --replace) shift ;; --no-modify-path) modify_path=false; shift ;; # The installer used to open cosyra when done. --no-start (and # COSYRA_NO_START) turned that off and stay accepted for old callers. --no-start) shift ;; -h|--help) usage; exit 0 ;; *) fail "Unknown option: $1" ;; esac done [ -z "$source_dir" ] || [ -z "$base_url" ] || fail 'Use either --from or --base-url' [ -z "$release_repository" ] || [ -n "$signing_key" ] || fail 'This installer has no signing key, so it cannot check a download' case "$install_dir" in /*) ;; *) fail '--dir must be an absolute path' ;; esac if [ -n "$base_url" ]; then [[ "$base_url" == https://* && "$base_url" != *'?'* && "$base_url" != *'#'* && "$base_url" != *[[:space:]]* ]] || fail '--base-url must be an HTTPS directory URL' fi system=$(uname -s) arch=$(uname -m) case "$system" in Darwin) system=darwin ;; Linux) system=linux ;; *) fail "Unsupported operating system: $system" ;; esac case "$arch" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) fail "Unsupported architecture: $arch" ;; esac # Prefer native Apple Silicon even if the installer shell is running in Rosetta. if [ "$system" = darwin ] && [ "$arch" = amd64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then arch=arm64 fi target="$install_dir/cosyra" [ ! -d "$target" ] || fail "Install target is a directory: $target" need tar if command -v sha256sum >/dev/null 2>&1; then checksum_command=(sha256sum) elif command -v shasum >/dev/null 2>&1; then checksum_command=(shasum -a 256) else fail 'sha256sum or shasum is required' fi [ -z "$signing_key" ] || need_signature_tool if [ -z "$version" ] && [ -n "$release_repository" ]; then if [ -n "$source_dir" ]; then read_folder_version elif [ -z "$base_url" ]; then read_stable_version fi fi [[ "$version" =~ $version_pattern ]] || fail 'Provide --version MAJOR.MINOR.PATCH (an optional prerelease suffix is allowed)' if [ -n "$release_repository" ] && [ -z "$source_dir" ] && [ -z "$base_url" ]; then base_url="https://github.com/$release_repository/releases/download/cosyra-v$version" fi asset="cosyra_${version}_${system}_${arch}.tar.gz" if [ -n "$signing_key" ]; then proof=(manifest.json manifest.json.sshsig) else proof=("$asset.sha256") fi temporary=$(mktemp -d "${TMPDIR:-/tmp}/cosyra-install.XXXXXXXX") staged='' cleanup() { [ -z "$staged" ] || rm -f -- "$staged" rm -rf -- "$temporary" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM printf 'Installing Cosyra %s for %s/%s\n' "$version" "$system" "$arch" if [ -n "$source_dir" ]; then for file in "$asset" "${proof[@]}"; do [ -f "$source_dir/$file" ] || fail "$source_dir has no $file. It comes with the release." cp -- "$source_dir/$file" "$temporary/" done elif [ -n "$base_url" ]; then need curl # The archive is the slow part. Show curl's bar when someone is watching; # stderr stays the terminal even when the script itself arrives on a pipe. archive_progress=(--silent --show-error) if [ -t 2 ]; then archive_progress=(--progress-bar) fi printf 'Downloading %s\n' "$asset" for file in "$asset" "${proof[@]}"; do progress=(--silent --show-error) if [ "$file" = "$asset" ]; then progress=("${archive_progress[@]}") fi curl --fail "${progress[@]}" --location --proto '=https' --proto-redir '=https' \ --connect-timeout 15 --max-time 180 --retry 2 \ "${base_url%/}/$file" --output "$temporary/$file" || fail "Could not download $file for version $version. Nothing was installed." done else need gh patterns=() for file in "$asset" "${proof[@]}"; do patterns+=(--pattern "$file") done # An explicit CLI tag avoids selecting the backend service's latest release. gh release download "cosyra-v$version" --repo "github.com/$private_repository" \ "${patterns[@]}" --dir "$temporary" fi if [ -n "$signing_key" ]; then printf 'Checking the signature\n' check_signed_manifest else printf 'Checking the archive\n' read -r expected filename extra < "$temporary/$asset.sha256" [[ "$expected" =~ ^[a-fA-F0-9]{64}$ ]] && [ "$filename" = "$asset" ] && [ -z "$extra" ] || fail 'Invalid checksum file' [ "$(wc -l < "$temporary/$asset.sha256" | tr -d ' ')" = 1 ] || fail 'Expected one checksum entry' [ "$(archive_sha256)" = "$expected" ] || fail 'Checksum mismatch; existing installation was preserved' fi # Extract only the regular binary to stdout. Archive paths/links never get # materialized in the install directory, even with a malformed archive. [ "$(tar -tzf "$temporary/$asset")" = cosyra ] || fail 'Archive must contain exactly one cosyra binary' listing=$(tar -tvzf "$temporary/$asset") [[ "$listing" == -* ]] || fail 'Archive binary must be a regular file' mkdir -p -- "$install_dir" staged=$(mktemp "$install_dir/.cosyra.XXXXXXXX") tar -xOzf "$temporary/$asset" cosyra > "$staged" chmod 755 "$staged" # Stdin is the script itself under curl | bash; the binary must not read it. reported=$("$staged" --version /dev/null || true)" in *$'\n remote '*) next_step='remote start' ;; *) next_step='connect' ;; esac if [ "$by_name" = true ]; then printf 'Next: run cosyra %s\n' "$next_step" else printf 'Next: run %q %s\n' "$target" "$next_step" fi