#!/bin/sh # Open Fork CLI installer and updater. # # curl -fsSL https:///install.sh -o install.sh && sh install.sh # sh install.sh --channel beta | --version 1.2.3 | --rollback | --uninstall [--keep-data] [--yes] # # Downloads the release manifest for this platform, verifies the manifest # signature (minisign, release key embedded at release time) and the archive's # SHA-256 and size, installs it next to earlier versions under # $OPENFORK_HOME/versions, and switches $OPENFORK_HOME/bin/openfork atomically. # The previous version is kept for --rollback. Without --channel, an update keeps # the installed channel. Installing changes nothing outside $OPENFORK_HOME (the # PATH line is printed, not written); --uninstall also removes Open Fork's caches, # remembered app sign-ins and, after asking (or with --yes), sessions and settings. set -eu RELEASE_BASE_URL="${OPENFORK_RELEASE_BASE_URL:-https://get.perkeel.com}" RELEASE_PUBLIC_KEY="${OPENFORK_RELEASE_PUBLIC_KEY:-@RELEASE_PUBLIC_KEY@}" OPENFORK_HOME="${OPENFORK_HOME:-$HOME/.openfork}" CHANNEL="" CHANNEL_GIVEN=0 VERSION="" ACTION="install" UNINSTALL_FLAGS="" fail() { printf 'openfork-install: %s\n' "$1" >&2; exit 1; } say() { printf '%s\n' "$1"; } while [ $# -gt 0 ]; do case "$1" in --channel) [ $# -ge 2 ] || fail "--channel needs stable or beta"; CHANNEL="$2"; CHANNEL_GIVEN=1; shift 2 ;; --version) [ $# -ge 2 ] || fail "--version needs a version"; VERSION="$2"; shift 2 ;; --rollback) ACTION="rollback"; shift ;; --uninstall) ACTION="uninstall"; shift ;; --keep-data) UNINSTALL_FLAGS="$UNINSTALL_FLAGS --keep-data"; shift ;; --yes) UNINSTALL_FLAGS="$UNINSTALL_FLAGS --yes"; shift ;; -h|--help) sed -n '2,14p' "$0"; exit 0 ;; *) fail "unknown option $1" ;; esac done # Without --channel an update stays on the channel recorded at install (beta stays beta). if [ "$CHANNEL_GIVEN" = 0 ]; then CHANNEL=$(sed -n 's/.*"channel":"\([a-z]*\)".*/\1/p' "${OPENFORK_HOME}/install.json" 2>/dev/null | head -n 1 || true) case "$CHANNEL" in stable|beta) ;; *) CHANNEL="stable" ;; esac fi case "$CHANNEL" in stable|beta) ;; *) fail "channel must be stable or beta" ;; esac case "$VERSION" in ""|[0-9]*.[0-9]*.[0-9]*) ;; *) fail "version must look like 1.2.3" ;; esac case "$VERSION" in *[!0-9A-Za-z.+-]*) fail "version contains unsupported characters" ;; esac BIN_DIR="$OPENFORK_HOME/bin" VERSIONS_DIR="$OPENFORK_HOME/versions" LINK="$BIN_DIR/openfork" if [ "$ACTION" = "uninstall" ]; then [ -d "$OPENFORK_HOME" ] || { say "Open Fork is not installed in $OPENFORK_HOME."; exit 0; } [ -f "$OPENFORK_HOME/install.json" ] || fail "$OPENFORK_HOME was not created by this installer; remove it yourself if intended" # The installed CLI removes what Open Fork created outside $OPENFORK_HOME: remembered app # sign-ins (OS credential store), caches and, after asking (or --yes), sessions and settings. # It asks on the terminal even when this script arrives through a pipe. if [ -x "$LINK" ] && "$LINK" uninstall --help 2>&1 | grep -q -- '--keep-data'; then if [ -t 2 ] && (: /dev/null; then cli_input=/dev/tty; else cli_input=/dev/null; fi # shellcheck disable=SC2086 # fixed flags only OPENFORK_HOME="$OPENFORK_HOME" "$LINK" uninstall --from-installer $UNINSTALL_FLAGS <"$cli_input" || fail "Open Fork's sign-ins or data folders were not all removed (see above); $OPENFORK_HOME was kept so you can retry" else say "The installed openfork cannot clean up after itself, so remembered app sign-ins (OS credential store, service dev.openfork.cli.app-session) and Open Fork's data folders (openfork under ~/.local/share, ~/.config, ~/.cache and ~/.local/state) were left; remove them yourself if intended." fi rm -rf -- "$OPENFORK_HOME" say "Removed $OPENFORK_HOME. Remove its bin directory from your PATH if you added it." exit 0 fi if [ "$ACTION" = "rollback" ]; then [ -f "$OPENFORK_HOME/previous" ] || fail "no previous version to roll back to" previous=$(cat "$OPENFORK_HOME/previous") [ -x "$VERSIONS_DIR/$previous/bin/openfork" ] || fail "previous version $previous is missing" current=$(cat "$OPENFORK_HOME/current" 2>/dev/null || true) ln -sfn "$VERSIONS_DIR/$previous/bin/openfork" "$BIN_DIR/.openfork.next" && mv -f "$BIN_DIR/.openfork.next" "$LINK" printf '%s\n' "$previous" > "$OPENFORK_HOME/current" [ -n "$current" ] && printf '%s\n' "$current" > "$OPENFORK_HOME/previous" say "Rolled back to Open Fork $previous." exit 0 fi case "$RELEASE_BASE_URL" in @*) fail "this is the unreleased installer template; download install.sh from your Open Fork release domain" ;; https://*) ;; http://127.0.0.1:*|http://localhost:*) [ "${OPENFORK_INSTALL_ALLOW_LOOPBACK:-}" = "1" ] || fail "loopback release servers are for installer tests only" ;; *) fail "release base URL must use HTTPS" ;; esac os=$(uname -s); arch=$(uname -m) case "$os" in Darwin) os=darwin ;; Linux) os=linux ;; *) fail "unsupported operating system $os (Windows: use the zip from the release page)" ;; esac case "$arch" in x86_64|amd64) arch=x64 ;; arm64|aarch64) arch=arm64 ;; *) fail "unsupported architecture $arch" ;; esac platform="$os-$arch" for tool in curl tar; do command -v "$tool" >/dev/null 2>&1 || fail "$tool is required"; done if command -v sha256sum >/dev/null 2>&1; then sha256() { sha256sum "$1" | cut -d' ' -f1; } elif command -v shasum >/dev/null 2>&1; then sha256() { shasum -a 256 "$1" | cut -d' ' -f1; } else fail "sha256sum or shasum is required"; fi work=$(mktemp -d "${TMPDIR:-/tmp}/openfork-install.XXXXXX") trap 'rm -rf -- "$work"' EXIT INT TERM fetch() { curl -fsSL --proto '=https,http' --max-time 600 --retry 2 -o "$2" "$1" || fail "download failed: $1"; } # Until the first stable release, a fresh install without --channel or --version takes beta. if [ "$CHANNEL_GIVEN" = 0 ] && [ -z "$VERSION" ] && [ "$CHANNEL" = stable ] && ! curl -fsI --proto '=https,http' --max-time 30 "$RELEASE_BASE_URL/channels/stable/manifest.json" >/dev/null 2>&1; then say "No stable release yet; installing the beta channel." CHANNEL=beta fi manifest_path="channels/$CHANNEL/manifest.json" [ -n "$VERSION" ] && manifest_path="versions/$VERSION/manifest.json" fetch "$RELEASE_BASE_URL/$manifest_path" "$work/manifest.json" case "$RELEASE_PUBLIC_KEY" in # Beta may ship unsigned (HTTPS + SHA-256 only, docs/design/23); stable always needs the signing key. @*) [ "${OPENFORK_INSTALL_ALLOW_UNSIGNED:-}" = "1" ] || [ "$CHANNEL" = beta ] || fail "no release signing key is embedded in this installer; refusing an unsigned manifest" [ "$CHANNEL" = beta ] && say "Warning: unsigned beta release; verified by HTTPS and SHA-256 only." ;; *) command -v minisign >/dev/null 2>&1 || fail "minisign is required to verify the release signature (https://jedisct1.github.io/minisign/)" fetch "$RELEASE_BASE_URL/$manifest_path.minisig" "$work/manifest.json.minisig" minisign -Vm "$work/manifest.json" -x "$work/manifest.json.minisig" -P "$RELEASE_PUBLIC_KEY" -q || fail "release signature verification failed" ;; esac # The manifest is small, fixed-format JSON written by tools/release/build_manifest.py: # one artifact per line as "platform url sha256 size" inside a "lines" block. field() { sed -n "s/^ *\"$1\": *\"\\([^\"]*\\)\".*/\\1/p" "$work/manifest.json" | head -n 1; } manifest_version=$(field version) manifest_channel=$(field channel) [ -n "$manifest_version" ] || fail "manifest has no version" case "$manifest_version" in *[!0-9A-Za-z.+-]*) fail "manifest version is malformed" ;; esac [ -z "$VERSION" ] || [ "$VERSION" = "$manifest_version" ] || fail "manifest version $manifest_version does not match requested $VERSION" [ -n "$VERSION" ] || [ "$manifest_channel" = "$CHANNEL" ] || fail "manifest channel does not match $CHANNEL" line=$(sed -n "s/^ *\"$platform \\([^ ]*\\) \\([0-9a-f]\\{64\\}\\) \\([0-9]*\\)\",\\{0,1\\}$/\\1 \\2 \\3/p" "$work/manifest.json" | head -n 1) [ -n "$line" ] || fail "release $manifest_version has no build for $platform" # shellcheck disable=SC2086 # split the checked "url sha256 size" line into its fields set -- $line artifact_url=$1; artifact_sha=$2; artifact_size=$3 case "$artifact_url" in https://*|"$RELEASE_BASE_URL"/*) ;; *) fail "artifact URL is not on the release domain" ;; esac # Keep an installer as $BIN_DIR/openfork-install for `openfork upgrade`. Preferred: this release's # own installer, downloaded and kept only if it matches the installer SHA-256 in the manifest # verified above (the same signature and digest rules as the archive), so the kept updater moves # with each release. Otherwise, when run from a file, that file is kept. Run from a pipe # (curl ... | sh) $0 is the shell, so without a verified copy nothing is kept. installer_marker='# Open Fork CLI installer and updater.' release_installer() { installer_sha=$(field installerSha256) case "$installer_sha" in ""|*[!0-9a-f]*) return 1 ;; esac [ "${#installer_sha}" -eq 64 ] || return 1 curl -fsSL --proto '=https,http' --max-time 120 --retry 2 -o "$work/install.sh" "$RELEASE_BASE_URL/versions/$manifest_version/install.sh" 2>/dev/null || return 1 [ "$(sha256 "$work/install.sh")" = "$installer_sha" ] || return 1 [ "$(sed -n 2p "$work/install.sh")" = "$installer_marker" ] } keep_installer() { if release_installer; then cp "$work/install.sh" "$BIN_DIR/.openfork-install.next" || return 1 elif [ -f "$0" ] && [ "$(sed -n 2p "$0" 2>/dev/null)" = "$installer_marker" ]; then cp "$0" "$BIN_DIR/.openfork-install.next" || return 1 else return 1 fi chmod 755 "$BIN_DIR/.openfork-install.next" && mv -f "$BIN_DIR/.openfork-install.next" "$BIN_DIR/openfork-install" } installer_hint() { say "The installer could not keep a verified copy of itself, so 'openfork upgrade' cannot work yet." say "Download install.sh to a file and run it once: curl -fsSL $RELEASE_BASE_URL/install.sh -o install.sh && sh install.sh" } if [ -x "$VERSIONS_DIR/$manifest_version/bin/openfork" ] && [ "$(cat "$OPENFORK_HOME/current" 2>/dev/null || true)" = "$manifest_version" ]; then say "Open Fork $manifest_version is already installed and active." if [ ! -f "$BIN_DIR/openfork-install" ]; then if keep_installer; then say "Kept the installer for updates: $BIN_DIR/openfork-install"; else installer_hint; fi fi exit 0 fi fetch "$artifact_url" "$work/openfork.tar.gz" actual_size=$(wc -c < "$work/openfork.tar.gz" | tr -d ' ') [ "$actual_size" = "$artifact_size" ] || fail "downloaded size $actual_size does not match the manifest" [ "$(sha256 "$work/openfork.tar.gz")" = "$artifact_sha" ] || fail "SHA-256 does not match the manifest" # Refuse absolute or parent-relative entries before anything is extracted. tar -tzf "$work/openfork.tar.gz" > "$work/entries" || fail "archive is unreadable" if grep -Eq '^/|(^|/)\.\.(/|$)' "$work/entries"; then fail "archive contains unsafe paths"; fi mkdir -p "$work/extract" tar -xzf "$work/openfork.tar.gz" -C "$work/extract" [ -f "$work/extract/bin/openfork" ] && [ ! -L "$work/extract/bin/openfork" ] || fail "archive has no regular bin/openfork" mkdir -p "$BIN_DIR" "$VERSIONS_DIR" chmod 700 "$OPENFORK_HOME" staging="$VERSIONS_DIR/.$manifest_version.partial" rm -rf -- "$staging" mv "$work/extract" "$staging" chmod 755 "$staging/bin/openfork" "$staging/bin/openfork" --version >/dev/null 2>&1 || fail "the downloaded build does not run on this machine" rm -rf -- "${VERSIONS_DIR:?}/${manifest_version:?}" mv "$staging" "$VERSIONS_DIR/$manifest_version" previous=$(cat "$OPENFORK_HOME/current" 2>/dev/null || true) ln -sfn "$VERSIONS_DIR/$manifest_version/bin/openfork" "$BIN_DIR/.openfork.next" && mv -f "$BIN_DIR/.openfork.next" "$LINK" printf '%s\n' "$manifest_version" > "$OPENFORK_HOME/current" [ -n "$previous" ] && [ "$previous" != "$manifest_version" ] && printf '%s\n' "$previous" > "$OPENFORK_HOME/previous" if keep_installer; then installer_kept=1; else installer_kept=0; fi printf '{"channel":"%s","version":"%s","releaseBaseUrl":"%s"}\n' "$CHANNEL" "$manifest_version" "$RELEASE_BASE_URL" > "$OPENFORK_HOME/install.json" say "Installed Open Fork $manifest_version ($platform, $CHANNEL)." case ":$PATH:" in *":$BIN_DIR:"*) ;; *) say "Add it to your PATH: export PATH=\"$BIN_DIR:\$PATH\"" ;; esac if [ "$installer_kept" = 1 ]; then say "Roll back with: sh $BIN_DIR/openfork-install --rollback"; else installer_hint; fi say "Next: pair your own Claude or ChatGPT subscription with: openfork setup" # Interactive installs pair right away; piped, CI and scripted installs only print the hint. if [ -t 0 ] && [ -t 1 ] && [ -z "${CI:-}" ] && [ -z "${OPENFORK_NO_SETUP:-}" ]; then "$LINK" setup || true; fi