From 95656189d5b3b9522be4e5461e042174b1bc7a3b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 18:20:57 +0000 Subject: [PATCH] feat(cli): add curl bootstrap installer Co-Authored-By: Paperclip --- package.json | 1 + scripts/install-sh-fixtures/npx | 6 + scripts/install.sh | 367 ++++++++++++++++++++++++++++++ scripts/test-install-sh-docker.sh | 107 +++++++++ 4 files changed, 481 insertions(+) create mode 100755 scripts/install-sh-fixtures/npx create mode 100755 scripts/install.sh create mode 100755 scripts/test-install-sh-docker.sh diff --git a/package.json b/package.json index a0844d17f80..406bcdba172 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "check:token-gates": "node scripts/check-token-gates.mjs", "check:no-git-push": "node scripts/check-no-git-push.mjs", "test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs", + "test:install-sh-docker": "./scripts/test-install-sh-docker.sh", "test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:hermes-gateway-join": "./scripts/smoke/hermes-gateway-join.sh", diff --git a/scripts/install-sh-fixtures/npx b/scripts/install-sh-fixtures/npx new file mode 100755 index 00000000000..ef289e08807 --- /dev/null +++ b/scripts/install-sh-fixtures/npx @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PAPERCLIP_INSTALL_TEST_LOG:?PAPERCLIP_INSTALL_TEST_LOG is required}" +node --version >"${PAPERCLIP_INSTALL_TEST_LOG}.node" +printf '%s\n' "$@" >"$PAPERCLIP_INSTALL_TEST_LOG" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000000..87d96253d35 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +set -euo pipefail + +MIN_NODE_MAJOR=20 +DEFAULT_NODE_MAJOR=22 +PAPERCLIP_PACKAGE="paperclipai" + +CANARY=0 +VERSION="" +REF="" +REPO="" +NO_ONBOARD=0 +NO_PROMPT=0 +INSTALL_SERVICE=0 +DRY_RUN=0 +VERBOSE=0 +TEMP_DIR="" + +usage() { + cat <<'EOF' +Install Paperclip on macOS, Linux, or WSL2. + +Usage: + curl -fsSL https://paperclip.ing/install.sh | bash + curl -fsSL https://paperclip.ing/install.sh | bash -s -- [options] + +Options: + --canary Install the canary channel + --version Install an exact published version + --ref Install a GitHub branch, tag, or commit + --repo Override the GitHub repository + --no-onboard Do not start onboarding after installation + --no-prompt Run non-interactively + --install-service Install the per-user Paperclip service + --dry-run Print the install plan without changing files + --verbose Enable verbose installer output + -h, --help Show this help + +Every option also has a PAPERCLIP_INSTALL_* environment equivalent, for example +PAPERCLIP_INSTALL_REF=master and PAPERCLIP_INSTALL_NO_PROMPT=1. +EOF +} + +log() { + printf '[paperclip] %s\n' "$*" +} + +fail() { + printf '[paperclip] error: %s\n' "$*" >&2 + exit 1 +} + +parse_bool() { + local name="$1" + local value="${2:-}" + + case "${value,,}" in + ""|0|false|no|off) printf '0' ;; + 1|true|yes|on) printf '1' ;; + *) fail "$name must be one of: 1, 0, true, false, yes, no, on, off" ;; + esac +} + +require_value() { + local option="$1" + local value="${2:-}" + [ -n "$value" ] || fail "$option requires a value" +} + +cleanup() { + if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then + rm -rf "$TEMP_DIR" + fi +} + +trap cleanup EXIT + +CANARY="$(parse_bool PAPERCLIP_INSTALL_CANARY "${PAPERCLIP_INSTALL_CANARY:-}")" +VERSION="${PAPERCLIP_INSTALL_VERSION:-}" +REF="${PAPERCLIP_INSTALL_REF:-}" +REPO="${PAPERCLIP_INSTALL_REPO:-}" +NO_ONBOARD="$(parse_bool PAPERCLIP_INSTALL_NO_ONBOARD "${PAPERCLIP_INSTALL_NO_ONBOARD:-}")" +NO_PROMPT="$(parse_bool PAPERCLIP_INSTALL_NO_PROMPT "${PAPERCLIP_INSTALL_NO_PROMPT:-}")" +INSTALL_SERVICE="$(parse_bool PAPERCLIP_INSTALL_INSTALL_SERVICE "${PAPERCLIP_INSTALL_INSTALL_SERVICE:-}")" +DRY_RUN="$(parse_bool PAPERCLIP_INSTALL_DRY_RUN "${PAPERCLIP_INSTALL_DRY_RUN:-}")" +VERBOSE="$(parse_bool PAPERCLIP_INSTALL_VERBOSE "${PAPERCLIP_INSTALL_VERBOSE:-}")" + +while [ "$#" -gt 0 ]; do + case "$1" in + --canary) + CANARY=1 + shift + ;; + --version) + require_value "$1" "${2:-}" + VERSION="$2" + shift 2 + ;; + --ref) + require_value "$1" "${2:-}" + REF="$2" + shift 2 + ;; + --repo) + require_value "$1" "${2:-}" + REPO="$2" + shift 2 + ;; + --no-onboard) + NO_ONBOARD=1 + shift + ;; + --no-prompt) + NO_PROMPT=1 + shift + ;; + --install-service) + INSTALL_SERVICE=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --verbose) + VERBOSE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +[ "$#" -eq 0 ] || fail "unexpected argument: $1" + +if [ "$CANARY" = "1" ] && [ -n "$VERSION" ]; then + fail "--canary and --version cannot be used together" +fi + +if [ ! -t 0 ] || [ ! -t 1 ]; then + NO_PROMPT=1 +fi + +if [ "$VERBOSE" = "1" ]; then + set -x +fi + +OS="$(uname -s 2>/dev/null || true)" +ARCH="$(uname -m 2>/dev/null || true)" + +case "$OS" in + Darwin) OS_NAME="macos" ;; + Linux) OS_NAME="linux" ;; + *) fail "unsupported operating system: ${OS:-unknown}. Use macOS, Linux, or WSL2." ;; +esac + +case "$ARCH" in + x86_64|amd64) ARCH_NAME="x64" ;; + arm64|aarch64) ARCH_NAME="arm64" ;; + *) fail "unsupported architecture: ${ARCH:-unknown}. Supported architectures: x64, arm64." ;; +esac + +log "Detected $OS_NAME/$ARCH_NAME" + +node_major() { + local version + version="$(node --version 2>/dev/null || true)" + version="${version#v}" + printf '%s' "${version%%.*}" +} + +has_supported_node() { + local major + command -v node >/dev/null 2>&1 || return 1 + major="$(node_major)" + [[ "$major" =~ ^[0-9]+$ ]] || return 1 + [ "$major" -ge "$MIN_NODE_MAJOR" ] || return 1 + command -v npm >/dev/null 2>&1 || return 1 + command -v npx >/dev/null 2>&1 || return 1 +} + +print_command() { + printf '[paperclip] +' + printf ' %q' "$@" + printf '\n' +} + +confirm_command() { + print_command "$@" + if [ "$NO_PROMPT" = "1" ]; then + return 0 + fi + + local answer + printf '[paperclip] Run this command? [y/N] ' >/dev/tty + IFS= read -r answer /dev/null 2>&1 || fail "sudo is required to install Node.js with the system package manager" + run_command sudo "$@" +} + +ensure_temp_dir() { + if [ -z "$TEMP_DIR" ]; then + TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-install.XXXXXX")" + fi +} + +download_checked_script() { + local url="$1" + local destination="$2" + + command -v curl >/dev/null 2>&1 || fail "curl is required to bootstrap Node.js" + curl --proto '=https' --tlsv1.2 -fsSL "$url" -o "$destination" + [ -s "$destination" ] || fail "downloaded script is empty: $url" + [ "$(head -c 2 "$destination")" = '#!' ] || fail "downloaded file is not an executable script: $url" + bash -n "$destination" || fail "downloaded script failed syntax validation: $url" +} + +check_version_manager() { + if [ -n "${NVM_DIR:-}" ] || [ -d "${HOME:-}/.nvm" ]; then + fail "nvm was detected. Run 'nvm install ${DEFAULT_NODE_MAJOR}' and retry this installer." + fi + if command -v asdf >/dev/null 2>&1 || [ -d "${HOME:-}/.asdf" ]; then + fail "asdf was detected. Run 'asdf install nodejs ${DEFAULT_NODE_MAJOR}' and retry this installer." + fi +} + +install_node_macos() { + if ! command -v brew >/dev/null 2>&1; then + ensure_temp_dir + local brew_installer="$TEMP_DIR/homebrew-install.sh" + log "Homebrew is required to install Node.js" + download_checked_script "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" "$brew_installer" + if [ "$NO_PROMPT" = "1" ]; then + run_command env NONINTERACTIVE=1 /bin/bash "$brew_installer" + else + run_command /bin/bash "$brew_installer" + fi + + if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [ -x /usr/local/bin/brew ]; then + eval "$(/usr/local/bin/brew shellenv)" + fi + fi + + command -v brew >/dev/null 2>&1 || fail "Homebrew installation completed but 'brew' is not available on PATH" + run_command brew install node +} + +install_node_apt() { + ensure_temp_dir + local nodesource_installer="$TEMP_DIR/nodesource-setup.sh" + run_privileged env DEBIAN_FRONTEND=noninteractive apt-get update + run_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl + download_checked_script "https://deb.nodesource.com/setup_${DEFAULT_NODE_MAJOR}.x" "$nodesource_installer" + run_privileged env DEBIAN_FRONTEND=noninteractive bash "$nodesource_installer" + run_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs +} + +install_node_dnf() { + ensure_temp_dir + local nodesource_installer="$TEMP_DIR/nodesource-setup.sh" + run_privileged dnf install -y ca-certificates curl + download_checked_script "https://rpm.nodesource.com/setup_${DEFAULT_NODE_MAJOR}.x" "$nodesource_installer" + run_privileged bash "$nodesource_installer" + run_privileged dnf install -y nodejs +} + +install_node_linux() { + if command -v apt-get >/dev/null 2>&1; then + install_node_apt + elif command -v dnf >/dev/null 2>&1; then + install_node_dnf + elif command -v pacman >/dev/null 2>&1; then + run_privileged pacman -Sy --noconfirm --needed nodejs npm + elif command -v apk >/dev/null 2>&1; then + run_privileged apk add --no-cache nodejs npm + else + fail "no supported Node.js package manager found. Supported: apt, dnf, pacman, apk." + fi +} + +if has_supported_node; then + log "Using Node.js $(node --version)" +else + if command -v node >/dev/null 2>&1; then + log "Node.js $(node --version 2>/dev/null || printf unknown) is too old; Node.js >= $MIN_NODE_MAJOR is required" + else + log "Node.js was not found" + fi + check_version_manager + log "Installing Node.js $DEFAULT_NODE_MAJOR" + if [ "$OS_NAME" = "macos" ]; then + install_node_macos + else + install_node_linux + fi + has_supported_node || fail "Node.js installation finished, but Node.js >= $MIN_NODE_MAJOR with npm/npx is not available" + log "Installed Node.js $(node --version)" +fi + +PACKAGE_SPEC="$PAPERCLIP_PACKAGE@latest" +if [ "$CANARY" = "1" ]; then + PACKAGE_SPEC="$PAPERCLIP_PACKAGE@canary" +elif [ -n "$VERSION" ]; then + PACKAGE_SPEC="$PAPERCLIP_PACKAGE@$VERSION" +fi + +INSTALL_ARGS=(install) +[ "$CANARY" = "1" ] && INSTALL_ARGS+=(--canary) +[ -n "$VERSION" ] && INSTALL_ARGS+=(--version "$VERSION") +[ -n "$REF" ] && INSTALL_ARGS+=(--ref "$REF") +[ -n "$REPO" ] && INSTALL_ARGS+=(--repo "$REPO") +[ "$NO_PROMPT" = "1" ] && INSTALL_ARGS+=(--no-prompt) +[ "$INSTALL_SERVICE" = "1" ] && INSTALL_ARGS+=(--install-service) +[ "$DRY_RUN" = "1" ] && INSTALL_ARGS+=(--dry-run) +[ "$VERBOSE" = "1" ] && INSTALL_ARGS+=(--verbose) + +log "Delegating to the Paperclip CLI" +print_command npx --yes "$PACKAGE_SPEC" "${INSTALL_ARGS[@]}" +npx --yes "$PACKAGE_SPEC" "${INSTALL_ARGS[@]}" + +if [ "$DRY_RUN" = "1" ]; then + exit 0 +fi + +if [ "$NO_ONBOARD" = "0" ] && [ -t 0 ] && [ -t 1 ]; then + if command -v paperclipai >/dev/null 2>&1; then + exec paperclipai onboard + elif [ -x "${HOME:-}/.local/bin/paperclipai" ]; then + exec "${HOME}/.local/bin/paperclipai" onboard + else + fail "Paperclip was installed, but 'paperclipai' is not available on PATH. Open a new shell and run 'paperclipai onboard'." + fi +fi + +if [ "$NO_ONBOARD" = "0" ]; then + log "Installation complete. Next: paperclipai onboard" +else + log "Installation complete." +fi diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh new file mode 100755 index 00000000000..b010e84e8d1 --- /dev/null +++ b/scripts/test-install-sh-docker.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RESULTS_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-install-sh.XXXXXX")" +KEEP_RESULTS="${KEEP_RESULTS:-0}" + +cleanup() { + if [ "$KEEP_RESULTS" = "1" ]; then + printf 'Kept installer test results at %s\n' "$RESULTS_DIR" + return + fi + rm -rf "$RESULTS_DIR" +} + +trap cleanup EXIT + +command -v docker >/dev/null 2>&1 || { + echo "docker is required" >&2 + exit 1 +} + +run_shellcheck() { + docker run --rm \ + -v "$REPO_ROOT:/work:ro" \ + -w /work \ + koalaman/shellcheck:stable \ + scripts/install.sh scripts/test-install-sh-docker.sh scripts/install-sh-fixtures/npx +} + +run_with_node() { + local name="$1" + shift + docker run --rm \ + -v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \ + -v "$RESULTS_DIR:/results" \ + -e "PAPERCLIP_INSTALL_TEST_LOG=/results/$name.args" \ + -e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + node:22-bookworm-slim \ + "$@" +} + +assert_line() { + local file="$1" + local expected="$2" + grep -Fx -- "$expected" "$file" >/dev/null || { + printf 'Expected %q in %s\n' "$expected" "$file" >&2 + cat "$file" >&2 + exit 1 + } +} + +echo "==> shellcheck" +run_shellcheck + +echo "==> existing Node" +run_with_node with-node bash /paperclip-scripts/install.sh --no-onboard +assert_line "$RESULTS_DIR/with-node.args" "paperclipai@latest" +assert_line "$RESULTS_DIR/with-node.args" "install" +assert_line "$RESULTS_DIR/with-node.args" "--no-prompt" + +echo "==> --ref master" +run_with_node ref-master bash /paperclip-scripts/install.sh --ref master --no-onboard +assert_line "$RESULTS_DIR/ref-master.args" "--ref" +assert_line "$RESULTS_DIR/ref-master.args" "master" + +echo "==> piped --no-prompt" +run_with_node piped bash -c 'cat /paperclip-scripts/install.sh | bash -s -- --no-prompt --no-onboard' +assert_line "$RESULTS_DIR/piped.args" "--no-prompt" + +echo "==> environment twins" +docker run --rm \ + -v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \ + -v "$RESULTS_DIR:/results" \ + -e PAPERCLIP_INSTALL_TEST_LOG=/results/env.args \ + -e PAPERCLIP_INSTALL_VERSION=2026.722.0 \ + -e PAPERCLIP_INSTALL_REPO=example/paperclip \ + -e PAPERCLIP_INSTALL_INSTALL_SERVICE=1 \ + -e PAPERCLIP_INSTALL_NO_ONBOARD=1 \ + -e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + node:22-bookworm-slim \ + bash /paperclip-scripts/install.sh +assert_line "$RESULTS_DIR/env.args" "paperclipai@2026.722.0" +assert_line "$RESULTS_DIR/env.args" "--version" +assert_line "$RESULTS_DIR/env.args" "2026.722.0" +assert_line "$RESULTS_DIR/env.args" "--repo" +assert_line "$RESULTS_DIR/env.args" "example/paperclip" +assert_line "$RESULTS_DIR/env.args" "--install-service" + +echo "==> no Node, apt bootstrap" +docker run --rm \ + -v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \ + -v "$RESULTS_DIR:/results" \ + -e PAPERCLIP_INSTALL_TEST_LOG=/results/no-node.args \ + -e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + ubuntu:24.04 \ + bash -c 'apt-get update >/dev/null && apt-get install -y ca-certificates curl >/dev/null && bash /paperclip-scripts/install.sh --no-prompt --no-onboard' +assert_line "$RESULTS_DIR/no-node.args" "paperclipai@latest" +node_version="$(cat "$RESULTS_DIR/no-node.args.node")" +node_major="${node_version#v}" +node_major="${node_major%%.*}" +[ "$node_major" -ge 20 ] || { + printf 'Expected Node >= 20, got %s\n' "$node_version" >&2 + exit 1 +} + +echo "Installer Docker checks passed."