#!/bin/sh # Bolt Installer — https://sparcle.app/install.sh # Works on macOS and Linux. # # CANONICAL SOURCE OF TRUTH for the consumer `curl | sh` installer: THIS file, # bolt-native/scripts/install/install.sh. sparcle-site/public/install.sh is the # published copy that sparcle.app actually serves, and it must be brought back to # byte-identical with this one — `scripts/install/check-installer-sync.sh` (run by # scripts/preflight.sh) fails the ship otherwise. Publishing the site copy is a # PUBLIC change and needs review before it is deployed. (An earlier version of # this comment claimed the opposite direction — that this file was the mirror — # which contradicted the gate that enforces it.) # # Usage: # curl -fsSL https://sparcle.app/install.sh | sh # Bolt (free), latest # curl -fsSL https://sparcle.app/install.sh | sh -s -- personal 0.1.18 # Specific version (positional) # BOLT_VERSION=0.1.18 curl -fsSL https://sparcle.app/install.sh | sh # Specific version (env) # # Backwards-compat: `personal`, `free`, `trial`, and `enterprise` are all # accepted as the edition argument and resolve to the same free Bolt build. # # What this does: # 1. Detects your OS and architecture # 2. Resolves the target version (BOLT_VERSION env > positional arg > /releases/latest) # 3. Downloads the correct installer from GitHub Releases (with on-disk cache) # 4. On Linux, auto-falls-back to the most recent release that ships Linux artifacts # (if BOLT_VERSION is not pinned by the user) # 5. Installs to /Applications (admin macOS) or ~/Applications (non-admin macOS), and ~/.local/bin on Linux # 6. Marks the app as trusted for your OS to launch safely # 7. Launches the app # # Re-runs are network-free when the cached download still matches the remote # (per-version cache at ~/.cache/bolt-installer/v/ — override with # BOLT_INSTALLER_CACHE_DIR). # # What you SEE vs. what is RECORDED: # The screen shows one line saying the install started, then ONE live line # underneath it that is rewritten in place while the work happens — # # Downloading [##############····················] 42% 68 MB / 163 MB # # — and finally the result. The live line is erased when it is done, so the # finished screen is four lines and no paths. Every step, every command's # output and every diagnostic is written to the log instead: # macOS ~/Library/Logs/Bolt/install-.log # Linux ~/.local/state/bolt/install-.log # The progress line is NEVER written to the log — a log full of carriage # returns is unreadable — and with no terminal (`curl … | sh` in CI, output # redirected to a file) it degrades to one plain line per phase. # BOLT_VERBOSE=1 puts the full detail back on the screen as well. Nothing is # dropped in either mode — the log is written the same way both times. # BOLT_NO_PROGRESS=1 turns the live line off entirely. # # No password required. Safe to re-run — overwrites previous installation. set -e # fd 3 is the SCREEN, kept aside before stdout/stderr are pointed at the log by # setup_logging. `say` is the only thing that writes to it, so a subcommand can # never leak raw output onto a first-time user's terminal. exec 3>&1 # ── Config ─────────────────────────────────────────────────────────────────── FALLBACK_VERSION="0.1.0" GITHUB_REPO="SparcleHQ/sparcle.app" DEFAULT_BOLT_PG_RELEASES_URL="https://github.com/SparcleHQ/sparcle.app" DEFAULT_BOLT_PG_FALLBACK_RELEASES_URL="https://github.com/theseus-rs/postgresql-binaries" DEFAULT_BOLT_PG_PREWARM_REQUIRED="1" DEFAULT_BOLT_PG_VERSION="18.3.0" DEFAULT_BOLT_API_PORT_BASE="13018" DEFAULT_BOLT_API_PORT_RANGE="10" DOWNLOAD_RETRY_MAX="5" DOWNLOAD_RETRY_DELAY="2" CACHE_BASE_DIR="${BOLT_INSTALLER_CACHE_DIR:-${HOME}/.cache/bolt-installer}" CACHE_KEEP_VERSIONS="2" # How many candidate releases the per-platform walk-back will try before giving # up. More than one because a release can LIST an asset it cannot actually serve # (a truncated or failed upload), and the next-newest release usually can. WALKBACK_MAX_TRIES="4" # ── Screen vs. log ─────────────────────────────────────────────────────────── # QUIET_SCREEN=1 (the default) means stdout/stderr go to the log file and the # screen only ever sees `say`. BOLT_VERBOSE=1 flips it: the same stream is teed # to both. Every other helper below writes to stdout, so it lands wherever this # one decision sent it — there is no second place that decides what is visible. QUIET_SCREEN=1 [ "${BOLT_VERBOSE:-0}" = "1" ] && QUIET_SCREEN=0 LOG_DIR="" LOG_FILE="" LOG_FIFO="" LOG_TEE_PID="" # Lines appended to the closing summary — for things that changed WHAT the user # got (a different version, a degraded install location), which they need even # though the step that caused it is only in the log. SUMMARY_NOTES="" # ── Helpers ────────────────────────────────────────────────────────────────── # say — the user-facing voice. Screen + log, plain language, no colour codes. # info/ok/warn — detail. Log only, unless BOLT_VERBOSE=1. # note — a warn that ALSO earns a line in the closing summary. # fail — the end of the road: full text to the log, human sentence to the screen. say() { progress_clear; if [ "$QUIET_SCREEN" = "1" ]; then printf '%s\n' "$1" >&3; fi; printf '%s\n' "$1"; } info() { printf '==> %s\n' "$1"; } ok() { printf ' ok %s\n' "$1"; } warn() { printf ' ! %s\n' "$1"; } note() { warn "$1" if [ -n "$SUMMARY_NOTES" ]; then SUMMARY_NOTES="${SUMMARY_NOTES} $1"; else SUMMARY_NOTES=" $1"; fi } fail() { progress_done # Full text to wherever detail goes: the log in quiet mode, the terminal in # verbose mode or before setup_logging has run. printf 'FAILED: %b\n' "$1" # The screen copy is needed ONLY when stdout is not already the screen — # otherwise the user reads the same sentence twice. if [ "$QUIET_SCREEN" = "1" ] && [ -n "$LOG_FILE" ]; then printf '\n %b\n' "$1" >&3 fi # Paths appear HERE and nowhere else on the screen. A successful install does # not need to tell anyone where the bundle went — but a failed one does: these # two are the "what do I look at / what do I delete" answer, and asking the # user to open the log first to find them is one hop too many. [ -n "${INSTALL_APP_PATH:-}" ] && printf ' Install path: %s\n' "${INSTALL_APP_PATH}" >&3 [ -n "${DL_PATH:-}" ] && printf ' Downloaded file: %s\n' "${DL_PATH}" >&3 [ -n "$LOG_FILE" ] && printf ' Details: %s\n' "$LOG_FILE" >&3 printf '\n' >&3 exit 1 } # ── Live progress: one line, rewritten in place, SCREEN ONLY ───────────────── # The dominant wait is a ~160 MB download that `curl -o` runs completely # silently. A multi-minute silent install reads as a hang, so one line is drawn # on the screen (fd 3) and rewritten with a carriage return: # # Downloading [##############······················] 42% 68 MB / 163 MB # # It is erased before anything else is printed, so the finished screen is the # header line plus the result — the bar leaves no trace. # # It NEVER goes to the log. A log full of \r frames is unreadable, and the log # already carries a detailed line per step. # # Three modes, decided once by progress_init: # bar quiet mode on a real terminal — the in-place bar above # plain quiet mode with no terminal (`curl … | sh` in CI, stdout redirected # to a file, TERM=dumb): one line per phase plus a percentage line # every 20%. Readable when captured, and still visibly moving. # off BOLT_VERBOSE=1 — every step is already narrated on the screen. # # Honesty rule: a percentage is only ever shown when a real denominator exists # (bytes against Content-Length, or step N of a known N). Everything else uses # the moving block, which claims nothing. PROGRESS_MODE="off" PROGRESS_WIDTH=34 PROGRESS_PAD_TO=79 PROGRESS_BLANK="" PROGRESS_LABEL="" PROGRESS_PULSE=0 PROGRESS_MARK=-1 PROGRESS_STEP=0 PROGRESS_STEPS=0 PROGRESS_FILL="#" PROGRESS_DOT="." PROGRESS_TICK="1" # Terminal width, best effort. stdin is the script itself under `curl | sh`, so # the terminal has to be reached through /dev/tty rather than fd 0. progress_term_cols() { _tc="${COLUMNS:-}" if [ -z "$_tc" ] && [ -r /dev/tty ]; then _tc=$(stty size < /dev/tty 2>/dev/null | awk '{print $2}' || true) fi [ -n "$_tc" ] || _tc=$(tput cols 2>/dev/null || true) case "$_tc" in ''|*[!0-9]*) _tc=80 ;; esac printf '%s' "$_tc" return 0 } progress_repeat() { # -> _PR _pr_c="$1"; _pr_n="$2"; _PR="" while [ "$_pr_n" -gt 0 ]; do _PR="${_PR}${_pr_c}"; _pr_n=$(( _pr_n - 1 )); done return 0 } progress_init() { PROGRESS_MODE="off" [ "${BOLT_NO_PROGRESS:-0}" = "1" ] && return 0 # Verbose mode already prints every step; a rewriting bar would fight it. [ "$QUIET_SCREEN" = "1" ] || return 0 PROGRESS_MODE="plain" if [ -t 3 ]; then case "${TERM:-}" in ""|dumb|unknown) ;; *) PROGRESS_MODE="bar" ;; esac fi # Sub-second sleep is not POSIX. Probe once: a strict `sleep` that rejects it # must not turn every frame into an error message. if sleep 0.2 2>/dev/null; then PROGRESS_TICK="0.2"; else PROGRESS_TICK="1"; fi [ "$PROGRESS_MODE" = "bar" ] || return 0 # The unfilled cell is U+00B7 MIDDLE DOT, which needs a UTF-8 terminal to # occupy one column. On a non-UTF-8 locale it would render as two mojibake # bytes per cell and wreck the line, so fall back to ASCII there. case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8*|*utf-8*|*UTF8*|*utf8*) PROGRESS_DOT="·" ;; *) PROGRESS_DOT="." ;; esac _pi_cols=$(progress_term_cols) PROGRESS_PAD_TO=$(( _pi_cols - 1 )) [ "$PROGRESS_PAD_TO" -gt 100 ] && PROGRESS_PAD_TO=100 [ "$PROGRESS_PAD_TO" -lt 1 ] && PROGRESS_PAD_TO=1 # Everything except the bar costs 45 columns (indent + label + percentage + # the widest detail). A terminal too narrow for a bar still gets the label, # the percentage and the megabytes — just no bar. _pi_room=$(( _pi_cols - 45 )) if [ "$_pi_room" -lt 10 ]; then PROGRESS_WIDTH=0 elif [ "$_pi_room" -lt "$PROGRESS_WIDTH" ]; then PROGRESS_WIDTH="$_pi_room" fi progress_repeat " " "$PROGRESS_PAD_TO" PROGRESS_BLANK="$_PR" return 0 } # Build "[###···] " into _PB. < 0 means the indeterminate moving block. progress_bar() { _pb_n="$1" _PB="" [ "$PROGRESS_WIDTH" -gt 0 ] || return 0 if [ "$_pb_n" -lt 0 ]; then _pb_span=$(( PROGRESS_WIDTH - 3 )) [ "$_pb_span" -lt 1 ] && _pb_span=1 _pb_p=$(( PROGRESS_PULSE % (_pb_span * 2) )) [ "$_pb_p" -gt "$_pb_span" ] && _pb_p=$(( _pb_span * 2 - _pb_p )) _pb_hi=$(( _pb_p + 3 )) else _pb_p=0 _pb_hi="$_pb_n" fi _pb_i=0 while [ "$_pb_i" -lt "$PROGRESS_WIDTH" ]; do if [ "$_pb_i" -ge "$_pb_p" ] && [ "$_pb_i" -lt "$_pb_hi" ]; then _PB="${_PB}${PROGRESS_FILL}" else _PB="${_PB}${PROGRESS_DOT}" fi _pb_i=$(( _pb_i + 1 )) done _PB="[${_PB}] " return 0 } # Draw one frame. < 0 draws the moving block and NO percentage. # The line is padded to the terminal width every frame, so a shorter line can # never leave the tail of a longer one behind — and no ANSI erase is needed. progress_draw() { [ "$PROGRESS_MODE" = "bar" ] || return 0 _pd_pct="$1"; _pd_detail="$2" if [ "$_pd_pct" -ge 0 ]; then progress_bar $(( _pd_pct * PROGRESS_WIDTH / 100 )); else progress_bar -1; fi if [ "$PROGRESS_WIDTH" -gt 0 ]; then _pd_bw=$(( PROGRESS_WIDTH + 4 )); else _pd_bw=0; fi # 2 indent + 11 label + 2 + bar + 4 percentage + 3 + detail (detail is ASCII, # so ${#…} is its column count; the bar's width is known, never measured — # the middle dot is 2 bytes and 1 column). _pd_len=$(( 2 + 11 + 2 + _pd_bw + 4 + 3 + ${#_pd_detail} )) _pd_pad=$(( PROGRESS_PAD_TO - _pd_len )) [ "$_pd_pad" -lt 0 ] && _pd_pad=0 progress_repeat " " "$_pd_pad" if [ "$_pd_pct" -ge 0 ]; then printf '\r %-11s %s%3d%% %s%s' "$PROGRESS_LABEL" "$_PB" "$_pd_pct" "$_pd_detail" "$_PR" >&3 else printf '\r %-11s %s %s%s' "$PROGRESS_LABEL" "$_PB" "$_pd_detail" "$_PR" >&3 fi return 0 } # Wipe the bar line and put the cursor back at column 0. Unconditional (not # gated on "did we draw?") because the drawing can happen inside a command # substitution, whose variable updates never come back to this shell. progress_clear() { [ "$PROGRESS_MODE" = "bar" ] || return 0 printf '\r%s\r' "$PROGRESS_BLANK" >&3 return 0 } progress_done() { progress_clear PROGRESS_LABEL="" return 0 } # Start a phase.