#!/usr/bin/env sh # BetterSign Installer # # Installs bs, bs-server, and bs-gpg to ~/.bettersign/bin # # Usage (recommended): # curl --proto '=https' --tlsv1.2 -sSf https://sh.bettersign.io/install/install.sh | sh # # Options: # -y, --yes Accept all prompts automatically # --no-modify-path Don't add ~/.bettersign/bin to PATH # --install-service Install bs-server as a login service automatically # --no-install-service Skip service installation # --desktop Also install the desktop app (macOS only, downloads DMG) # --headless Install bs-server only (alias for --features bs-server) # --web-ui Install bs-server-web (alias for --features bs-server-web) # --no-default-features Disable the default feature set (cargo-style) # --features LIST Comma-separated features to add on top of defaults # Valid: bs, bs-gpg, bs-server, bs-server-web, desktop # -F LIST Short form of --features # --version VERSION Install a specific version (default: latest) # --bin-dir DIR Override install directory (default: ~/.bettersign/bin) # --base-url URL Override download base URL # (env: BETTERSIGN_DIST_SERVER) # -h, --help Show this help message set -eu # ── Globals (must be global for the EXIT trap to see them) ───────────────────── _TMPDIR="" ignore() { "$@" 2>/dev/null; return 0; } _cleanup() { [ -n "$_TMPDIR" ] && ignore rm -rf "$_TMPDIR"; return 0; } trap _cleanup EXIT INT TERM # ── Defaults ─────────────────────────────────────────────────────────────────── BETTERSIGN_HOME="${BETTERSIGN_HOME:-${HOME}/.bettersign}" BETTERSIGN_DIST_SERVER="${BETTERSIGN_DIST_SERVER:-https://sh.bettersign.io}" BETTERSIGN_CPUTYPE="${BETTERSIGN_CPUTYPE:-}" # override detected arch (e.g. x86_64 under Rosetta) # ── Terminal colors (stripped when stdout is not a tty) ──────────────────────── if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' else RED='' GREEN='' YELLOW='' CYAN='' BOLD='' DIM='' NC='' fi # ── Logging helpers ──────────────────────────────────────────────────────────── info() { printf "${CYAN}info${NC}: %s\n" "$*"; } success() { printf "${GREEN}ok${NC}: %s\n" "$*"; } warn() { printf "${YELLOW}warn${NC}: %s\n" "$*" >&2; } err() { printf "${RED}error${NC}: %s\n" "$*" >&2; } bold() { printf "${BOLD}%s${NC}\n" "$*"; } die() { err "$*"; exit 1; } # ── Argument parsing ─────────────────────────────────────────────────────────── AUTO_YES=0 OPT_MODIFY_PATH="" # empty = prompt OPT_INSTALL_SERVICE="" # empty = prompt OPT_DESKTOP=0 # Server variant: "web" = bs-server-web (embedded UI), "headless" = bs-server (API only) # empty = auto-detect from environment OPT_SERVER_VARIANT="" # Cargo-style feature selection. # OPT_NO_DEFAULT_FEATURES=1 suppresses the default feature set (bs + bs-gpg + # auto-detected server variant). OPT_FEATURES is a comma-separated list of # features to add on top of the defaults (or on top of nothing when # --no-default-features is set). OPT_NO_DEFAULT_FEATURES=0 OPT_FEATURES="" INSTALL_VERSION="latest" BIN_DIR="" OPT_SKELETON=0 parse_args() { while [ $# -gt 0 ]; do case "$1" in -y|--yes) AUTO_YES=1 ;; --no-modify-path) OPT_MODIFY_PATH=0 ;; --modify-path) OPT_MODIFY_PATH=1 ;; --install-service) OPT_INSTALL_SERVICE=1 ;; --no-install-service) OPT_INSTALL_SERVICE=0 ;; --desktop) OPT_DESKTOP=1 ;; --web-ui) OPT_SERVER_VARIANT="web" ;; --headless) OPT_SERVER_VARIANT="headless" ;; --no-default-features) OPT_NO_DEFAULT_FEATURES=1 ;; --features) shift; OPT_FEATURES="${OPT_FEATURES:+$OPT_FEATURES,}$1" ;; --features=*) OPT_FEATURES="${OPT_FEATURES:+$OPT_FEATURES,}${1#*=}" ;; -F) shift; OPT_FEATURES="${OPT_FEATURES:+$OPT_FEATURES,}$1" ;; -F=*) OPT_FEATURES="${OPT_FEATURES:+$OPT_FEATURES,}${1#*=}" ;; --version) shift; INSTALL_VERSION="$1" ;; --version=*) INSTALL_VERSION="${1#*=}" ;; --bin-dir) shift; BIN_DIR="$1" ;; --bin-dir=*) BIN_DIR="${1#*=}" ;; --skeleton) OPT_SKELETON=1 ;; --base-url) shift; BETTERSIGN_DIST_SERVER="$1" ;; --base-url=*) BETTERSIGN_DIST_SERVER="${1#*=}" ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $1. Run with --help for usage." ;; esac shift done } usage() { cat < sudo -u XDG_RUNTIME_DIR=/run/user/\$(id -u ) \ systemctl --user enable --now bettersign-server --base-url URL Override download base URL -h, --help Show this help ENVIRONMENT: BETTERSIGN_HOME Base directory (default: ~/.bettersign) BETTERSIGN_DIST_SERVER Download base URL (for mirrors or S3) BETTERSIGN_CPUTYPE Override detected CPU arch (e.g. x86_64 under Rosetta) NO_COLOR Disable colored output EOF } # ── Platform / arch detection ───────────────────────────────────────────────── # Returns the normalised CPU arch used in S3 paths: arm64 | x86_64 detect_arch() { local _arch _arch="${BETTERSIGN_CPUTYPE:-$(uname -m)}" case "$_arch" in arm64|aarch64) echo "aarch64" ;; x86_64) echo "x86_64" ;; *) die "Unsupported architecture: $_arch" ;; esac } # Returns the S3 bucket name for the current platform. # macOS → macos # Amazon Linux → amazonlinux (must be checked before the generic rpm branch) # Linux → deb | rpm | arch (detected by package manager) detect_platform() { local _os _linux_id _os="$(uname -s)" case "$_os" in Darwin) echo "macos" ;; Linux) # Read ID from /etc/os-release (POSIX: source via sed, no bashisms). _linux_id="" if [ -f /etc/os-release ]; then _linux_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"' | head -1)" fi if [ "$_linux_id" = "amzn" ]; then echo "amazonlinux" elif need_cmd dpkg || need_cmd apt-get; then echo "deb" elif need_cmd rpm || need_cmd yum || need_cmd dnf; then echo "rpm" elif need_cmd pacman; then echo "arch" else warn "Could not detect Linux package manager — defaulting to deb binaries." echo "deb" fi ;; MINGW*|MSYS*|CYGWIN*) die "Windows detected. Please use install.ps1 instead:\n\n irm https://sh.bettersign.io/install/install.ps1 | iex\n\nOr run this script under WSL." ;; *) die "Unsupported operating system: $_os" ;; esac } # Detect whether we are on a desktop or a headless server. # Returns "desktop" or "headless". # # Heuristics (first match wins): # 1. DISPLAY / WAYLAND_DISPLAY set → desktop session active # 2. macOS → always desktop # 3. XDG_SESSION_TYPE = x11 | wayland → desktop # 4. systemd-detect-virt returns a VM/container type → headless server # 5. No /dev/tty → headless (piped install, CI, SSH without X forwarding) # 6. Fall back to headless to be safe detect_environment() { local _os _os="$(uname -s)" case "$_os" in Darwin) echo "desktop"; return ;; MINGW*|MSYS*|CYGWIN*) echo "desktop"; return ;; esac # Running inside a graphical session? if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then echo "desktop"; return fi case "${XDG_SESSION_TYPE:-}" in x11|wayland) echo "desktop"; return ;; esac # No graphical session detected → headless echo "headless" } # ── Dependency checks ────────────────────────────────────────────────────────── need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then return 1 fi return 0 } has_tty() { [ -c /dev/tty ]; } # Snap-packaged curl is sandboxed and frequently can't reach the internet. curl_is_snap() { local _curl_path _curl_path="$(command -v curl 2>/dev/null)" || return 1 case "$_curl_path" in /snap/*|*/snap/bin/*) return 0 ;; *) return 1 ;; esac } check_deps() { local _have_downloader=0 if need_cmd curl && ! curl_is_snap; then _have_downloader=1 fi if [ "$_have_downloader" = "0" ] && need_cmd wget; then _have_downloader=1 if need_cmd curl && curl_is_snap; then warn "curl is a snap package (sandboxed) — using wget instead." fi fi if [ "$_have_downloader" = "0" ]; then die "Neither curl nor wget found. Install one and re-run:\n apt-get install curl # Debian/Ubuntu\n yum install curl # RHEL/CentOS" fi } # ── Network helpers ──────────────────────────────────────────────────────────── _use_wget() { curl_is_snap || ! need_cmd curl; } _curl_retry_flags() { # --retry-all-errors (curl ≥ 7.71, 2020) retries on RECV_ERROR / connection # resets; probe via --help and include it when available. if curl --help 2>&1 | grep -q -- '--retry-all-errors'; then printf '%s' '--retry 3 --retry-delay 2 --retry-all-errors' else printf '%s' '--retry 3 --retry-delay 2' fi } fetch() { local _url="$1" local _dest="$2" if _use_wget; then wget --https-only --secure-protocol=TLSv1_2 \ -q --tries=3 --waitretry=2 \ -O "$_dest" "$_url" else # shellcheck disable=SC2046 curl --proto '=https' --tlsv1.2 --silent --show-error --fail \ --location $(_curl_retry_flags) \ --output "$_dest" "$_url" fi } fetch_stdout() { local _url="$1" if _use_wget; then wget --https-only --secure-protocol=TLSv1_2 \ -q --tries=3 -O - "$_url" else # shellcheck disable=SC2046 curl --proto '=https' --tlsv1.2 --silent --show-error --fail \ --location $(_curl_retry_flags) "$_url" fi } # ── Version resolution and VERSION file ─────────────────────────────────────── # When installing "latest", fetch version.txt so we know the actual version to # store in VERSION. Pinned installs just echo the version as-is. resolve_installed_version() { local _platform="$1" local _arch="$2" if [ "$INSTALL_VERSION" = "latest" ]; then fetch_stdout "${BETTERSIGN_DIST_SERVER}/${_platform}/latest/${_arch}/version.txt" \ | tr -d '[:space:]' else echo "$INSTALL_VERSION" fi } write_version() { local _version="$1" mkdir -p "$BETTERSIGN_HOME" printf '%s\n' "$_version" > "${BETTERSIGN_HOME}/VERSION" } # ── Environment file ─────────────────────────────────────────────────────────── # Writes ~/.bettersign/env — a sourceable file that safely prepends the bin # directory to PATH, mirroring the pattern rustup uses for ~/.cargo/env. write_env_file() { local _bin_dir="$1" local _env_file="${BETTERSIGN_HOME}/env" mkdir -p "$BETTERSIGN_HOME" cat > "$_env_file" < "$_env_file" <<'ENV_EOF' #!/bin/sh # BetterSign shell environment # Source from ~/.bashrc or ~/.profile: # . "$HOME/.bettersign/env" case ":${PATH}:" in *:"${HOME}/.bettersign/bin":*) ;; *) export PATH="${HOME}/.bettersign/bin:${PATH}" ;; esac ENV_EOF success "Wrote ${_env_file}" } # ── Install updater script ───────────────────────────────────────────────────── install_updater() { local _bin_dir="$1" local _updater="${_bin_dir}/bsup" cat > "$_updater" << 'UPDATER_EOF' #!/usr/bin/env sh # bsup — BetterSign updater # # Checks for a newer version and updates bs, bs-server, bs-server-web, and bs-gpg in place. # Only updates server variants that are already installed. # # Usage: # bsup # # Options: # --base-url URL Override download base URL (env: BETTERSIGN_DIST_SERVER) # -h, --help Show this help # # Environment: # BETTERSIGN_HOME Base directory (default: ~/.bettersign) # BETTERSIGN_DIST_SERVER Download base URL # NO_COLOR Disable colored output set -eu BETTERSIGN_HOME="${BETTERSIGN_HOME:-${HOME}/.bettersign}" BETTERSIGN_DIST_SERVER="${BETTERSIGN_DIST_SERVER:-https://sh.bettersign.io}" BETTERSIGN_CPUTYPE="${BETTERSIGN_CPUTYPE:-}" BIN_DIR="${BETTERSIGN_HOME}/bin" VERSION_FILE="${BETTERSIGN_HOME}/VERSION" if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' else RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC='' fi info() { printf "${CYAN}info${NC}: %s\n" "$*"; } success() { printf "${GREEN}ok${NC}: %s\n" "$*"; } warn() { printf "${YELLOW}warn${NC}: %s\n" "$*" >&2; } die() { printf "${RED}error${NC}: %s\n" "$*" >&2; exit 1; } parse_args() { while [ $# -gt 0 ]; do case "$1" in --base-url) shift; BETTERSIGN_DIST_SERVER="$1" ;; --base-url=*) BETTERSIGN_DIST_SERVER="${1#*=}" ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $1. Run with --help for usage." ;; esac shift done } usage() { cat </dev/null 2>&1; } fetch_stdout() { if _use_wget; then wget --https-only --secure-protocol=TLSv1_2 -q --tries=3 -O - "$1" else curl --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" fi } fetch() { if _use_wget; then wget --https-only --secure-protocol=TLSv1_2 -q --tries=3 --waitretry=2 -O "$2" "$1" else curl --proto '=https' --tlsv1.2 --silent --show-error --fail --location --output "$2" "$1" fi } detect_platform() { local _linux_id case "$(uname -s)" in Darwin) echo "macos" ;; Linux) _linux_id="" if [ -f /etc/os-release ]; then _linux_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"' | head -1)" fi if [ "$_linux_id" = "amzn" ]; then echo "amazonlinux" elif command -v dpkg >/dev/null 2>&1 || command -v apt-get >/dev/null 2>&1; then echo "deb" elif command -v rpm >/dev/null 2>&1 || command -v yum >/dev/null 2>&1 || command -v dnf >/dev/null 2>&1; then echo "rpm" elif command -v pacman >/dev/null 2>&1; then echo "arch" else warn "Could not detect Linux package manager — defaulting to deb binaries." echo "deb" fi ;; *) die "Unsupported OS: $(uname -s)" ;; esac } detect_arch() { local _a _a="${BETTERSIGN_CPUTYPE:-$(uname -m)}" case "$_a" in arm64|aarch64) echo "aarch64" ;; x86_64) echo "x86_64" ;; *) die "Unsupported architecture: $_a" ;; esac } _bs_server_was_running=0 _bs_server_web_was_running=0 stop_server_if_running() { local _name="$1" pgrep -x "$_name" >/dev/null 2>&1 || return 0 info "Stopping ${_name} for update..." case "$(uname -s)" in Darwin) local _plist="${HOME}/Library/LaunchAgents/io.bettersign.server.plist" if launchctl list "io.bettersign.server" >/dev/null 2>&1; then launchctl unload "$_plist" 2>/dev/null || true else pkill -x "$_name" 2>/dev/null || true fi ;; Linux) local _xrd="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export XDG_RUNTIME_DIR="$_xrd" if systemctl --user is-active bettersign-server.service >/dev/null 2>&1; then systemctl --user stop bettersign-server.service 2>/dev/null || true else pkill -x "$_name" 2>/dev/null || true fi ;; *) pkill -x "$_name" 2>/dev/null || true ;; esac case "$_name" in bs-server-web) _bs_server_web_was_running=1 ;; bs-server) _bs_server_was_running=1 ;; esac } restart_server_if_was_running() { local _name="$1" local _was_running=0 case "$_name" in bs-server-web) _was_running=$_bs_server_web_was_running ;; bs-server) _was_running=$_bs_server_was_running ;; esac [ "$_was_running" = "1" ] || return 0 info "Restarting ${_name}..." case "$(uname -s)" in Darwin) local _plist="${HOME}/Library/LaunchAgents/io.bettersign.server.plist" if [ -f "$_plist" ]; then launchctl load -w "$_plist" 2>/dev/null \ && success "${_name} restarted via launchd" \ || warn "launchd restart failed — start ${_name} manually" else "${BIN_DIR}/${_name}" & success "${_name} restarted (background)" fi ;; Linux) local _xrd="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export XDG_RUNTIME_DIR="$_xrd" if systemctl --user list-unit-files bettersign-server.service >/dev/null 2>&1; then systemctl --user start bettersign-server.service 2>/dev/null \ && success "${_name} restarted via systemd" \ || warn "systemd restart failed — start ${_name} manually" else "${BIN_DIR}/${_name}" & success "${_name} restarted (background)" fi ;; *) "${BIN_DIR}/${_name}" & success "${_name} restarted (background)" ;; esac } main() { parse_args "$@" local _platform _arch _remote _local _platform="$(detect_platform)" _arch="$(detect_arch)" info "Checking for updates..." _remote="$(fetch_stdout "${BETTERSIGN_DIST_SERVER}/${_platform}/latest/${_arch}/version.txt" \ | tr -d '[:space:]')" || die "Failed to fetch remote version" [ -n "$_remote" ] || die "Remote version.txt is empty" _local="$(cat "${VERSION_FILE}" 2>/dev/null | tr -d '[:space:]')" if [ "$_remote" = "$_local" ]; then success "Already up to date (${_local})" exit 0 fi if [ -n "$_local" ]; then printf "${BOLD}Updating:${NC} %s → %s\n\n" "$_local" "$_remote" else printf "${BOLD}Installing:${NC} %s\n\n" "$_remote" fi for _tool in bs bs-gpg; do info "Downloading ${_tool}..." fetch "${BETTERSIGN_DIST_SERVER}/${_platform}/latest/${_arch}/${_tool}" "${BIN_DIR}/${_tool}" \ || die "Failed to download ${_tool}" chmod +x "${BIN_DIR}/${_tool}" success "${_tool} updated" done # Stop running server variants before updating. for _tool in bs-server bs-server-web; do if [ -f "${BIN_DIR}/${_tool}" ]; then stop_server_if_running "$_tool" fi done # Update whichever server variant(s) are installed — don't add variants that weren't installed. for _tool in bs-server bs-server-web; do if [ -f "${BIN_DIR}/${_tool}" ]; then info "Downloading ${_tool}..." fetch "${BETTERSIGN_DIST_SERVER}/${_platform}/latest/${_arch}/${_tool}" "${BIN_DIR}/${_tool}" \ || die "Failed to download ${_tool}" chmod +x "${BIN_DIR}/${_tool}" success "${_tool} updated" fi done # Restart server variants that were running before the update. for _tool in bs-server bs-server-web; do if [ -f "${BIN_DIR}/${_tool}" ]; then restart_server_if_was_running "$_tool" fi done # Self-update bsup itself (best-effort — silent if not yet hosted on dist server). _bsup_new="$(mktemp 2>/dev/null)" if [ -n "$_bsup_new" ] && fetch "${BETTERSIGN_DIST_SERVER}/install/bsup" "$_bsup_new" 2>/dev/null; then chmod +x "$_bsup_new" mv "$_bsup_new" "${BIN_DIR}/bsup" success "bsup updated" else [ -z "$_bsup_new" ] || rm -f "$_bsup_new" fi printf '%s\n' "$_remote" > "$VERSION_FILE" printf "\n${GREEN}${BOLD}BetterSign updated to %s${NC}\n\n" "$_remote" } main "$@" UPDATER_EOF chmod +x "$_updater" success "bsup installed to ${_updater}" } # ── Download and install a single binary ─────────────────────────────────────── install_tool() { local _tool="$1" local _platform="$2" local _arch="$3" local _version="$4" local _bin_dir="$5" local _url="${BETTERSIGN_DIST_SERVER}/${_platform}/${_version}/${_arch}/${_tool}" local _dest="${_bin_dir}/${_tool}" info "Downloading ${_tool}..." fetch "$_url" "$_dest" || die "Failed to download ${_tool}\n URL: ${_url}" chmod +x "$_dest" success "${_tool} installed to ${_dest}" } # ── PATH configuration ───────────────────────────────────────────────────────── path_entry() { printf '\n# BetterSign\n. "$HOME/.bettersign/env"\n' } already_in_path() { case ":${PATH}:" in *":$1:"*) return 0 ;; *) return 1 ;; esac } configure_path() { local _bin_dir="$1" if already_in_path "$_bin_dir"; then info "$_bin_dir is already in PATH — skipping" return fi local _do_modify if [ -n "$OPT_MODIFY_PATH" ]; then _do_modify="$OPT_MODIFY_PATH" elif [ "$AUTO_YES" = "1" ]; then _do_modify=1 elif has_tty; then printf "\n${BOLD}PATH configuration${NC}\n" printf "Add ${CYAN}%s${NC} to PATH? [Y/n] " "$_bin_dir" local _ans read -r _ans /dev/null; then path_entry >> "$_rc" success "Added to $_rc" _added=1 fi fi done if [ "$_added" = "0" ]; then path_entry >> "${HOME}/.profile" success "Added to ~/.profile" fi } # ── Service setup ────────────────────────────────────────────────────────────── setup_service_macos() { local _binary="$1" local _plist="${HOME}/Library/LaunchAgents/io.bettersign.server.plist" mkdir -p "${HOME}/Library/LaunchAgents" cat > "$_plist" < Label io.bettersign.server ProgramArguments ${_binary} RunAtLoad KeepAlive StandardOutPath ${HOME}/.bettersign/logs/bs-server.log StandardErrorPath ${HOME}/.bettersign/logs/bs-server.log EOF mkdir -p "${HOME}/.bettersign/logs" launchctl load -w "$_plist" 2>/dev/null || true success "bs-server registered as launchd agent (starts at login)" info " Plist: $_plist" info " Logs: ~/.bettersign/logs/bs-server.log" info " Stop: launchctl unload -w $_plist" } setup_service_linux() { local _binary="$1" local _unit_dir="${HOME}/.config/systemd/user" local _unit="${_unit_dir}/bettersign-server.service" mkdir -p "$_unit_dir" cat > "$_unit" </dev/null; then success "Linger enabled for $USER" else warn "sudo loginctl enable-linger failed — bs-server may not start at boot." warn " Run manually: sudo loginctl enable-linger $USER" fi else info "Skipping linger. Run manually if needed: sudo loginctl enable-linger $USER" fi fi # systemctl --user requires XDG_RUNTIME_DIR; set it if the shell didn't. XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export XDG_RUNTIME_DIR if systemctl --user daemon-reload 2>/dev/null && \ systemctl --user enable --now bettersign-server.service 2>/dev/null; then success "bs-server enabled as systemd user service" info " Unit: $_unit" info " Status: systemctl --user status bettersign-server" info " Stop: systemctl --user disable --now bettersign-server" else warn "systemctl --user failed (D-Bus session may not be active yet)." warn " After logging in, run:" warn " systemctl --user daemon-reload" warn " systemctl --user enable --now bettersign-server" info " Unit: $_unit" fi } setup_service() { local _binary="$1" local _os _os="$(uname -s)" local _do_service if [ -n "$OPT_INSTALL_SERVICE" ]; then _do_service="$OPT_INSTALL_SERVICE" elif [ "$AUTO_YES" = "1" ]; then _do_service=1 elif has_tty; then printf "\n${BOLD}Service setup${NC}\n" printf "Install bs-server as a login service (starts automatically at login)? [Y/n] " local _ans read -r _ans "$_unit" <${NC}\n\n" printf " 2. Allow bs-server to run without an active login session:\n" printf " ${DIM}loginctl enable-linger ${NC}\n\n" printf " 3. Enable and start bs-server as that user:\n" printf " ${DIM}sudo -u XDG_RUNTIME_DIR=/run/user/\$(id -u ) \\\n" printf " systemctl --user daemon-reload${NC}\n" printf " ${DIM}sudo -u XDG_RUNTIME_DIR=/run/user/\$(id -u ) \\\n" printf " systemctl --user enable --now bettersign-server${NC}\n\n" printf " Linger must be enabled before step 3 so the user systemd instance\n" printf " is running when systemctl --user is invoked.\n\n" } # ── Desktop app install (macOS only) ────────────────────────────────────────── install_desktop_macos() { local _version="$1" local _arch="$2" local _url="${BETTERSIGN_DIST_SERVER}/macos/${_version}/${_arch}/BetterSign.dmg" local _tmpfile _tmpfile="$(mktemp /tmp/bettersign-XXXXXX.dmg)" info "Downloading BetterSign.dmg (${_arch})..." fetch "$_url" "$_tmpfile" || die "Failed to download ${_dmg}\n URL: ${_url}" info "Mounting disk image..." local _vol _vol="$(hdiutil attach "$_tmpfile" -nobrowse -readonly -noautoopen 2>/dev/null \ | grep '/Volumes/' | sed 's|.*/Volumes/|/Volumes/|')" [ -n "$_vol" ] || die "Failed to mount ${_dmg}" info "Installing BetterSign.app to /Applications..." cp -R "${_vol}/BetterSign.app" /Applications/ \ || die "Failed to copy BetterSign.app — check /Applications permissions" hdiutil detach "$_vol" -quiet 2>/dev/null || true ignore rm -f "$_tmpfile" success "BetterSign.app installed to /Applications" } install_desktop() { local _version="$1" local _arch="$2" case "$(uname -s)" in Darwin) install_desktop_macos "$_version" "$_arch" ;; *) warn "Desktop install (--desktop) is macOS only — skipping on $(uname -s)" ;; esac } # ── Feature resolution (cargo-style) ────────────────────────────────────────── # Valid feature names. Keep in sync with usage() and resolve_features(). _VALID_FEATURES="bs bs-gpg bs-server bs-server-web desktop" # Membership test: has_feature # is a space-separated list. Returns 0 if is in , else 1. has_feature() { local _needle="$1" local _hay=" $2 " case "$_hay" in *" $_needle "*) return 0 ;; *) return 1 ;; esac } # resolve_features # # Echoes a space-separated feature list to stdout. # is the detected environment ("desktop" or "headless"), used only to # pick the default server variant when OPT_SERVER_VARIANT is empty. # # Logic: # 1. If OPT_NO_DEFAULT_FEATURES is 0, seed with the default set: # - bs, bs-gpg # - server variant from OPT_SERVER_VARIANT, else from # - desktop if OPT_DESKTOP=1 # 2. Append every comma-separated item from OPT_FEATURES, validating each # against _VALID_FEATURES, skipping duplicates. # # Note: --no-default-features still honours --desktop / --web-ui / --headless # only via --features. The legacy flags only affect the default set; with # --no-default-features the caller must list features explicitly. resolve_features() { local _env="$1" local _feats="" if [ "$OPT_NO_DEFAULT_FEATURES" = "0" ]; then _feats="bs bs-gpg" if [ "$OPT_SERVER_VARIANT" = "web" ]; then _feats="$_feats bs-server-web" elif [ "$OPT_SERVER_VARIANT" = "headless" ]; then _feats="$_feats bs-server" elif [ "$_env" = "desktop" ]; then _feats="$_feats bs-server-web" else _feats="$_feats bs-server" fi if [ "$OPT_DESKTOP" = "1" ]; then _feats="$_feats desktop" fi fi # Walk the comma-separated OPT_FEATURES list. POSIX sh has no arrays; # swap commas for spaces and iterate via `for`. IFS is restored after. if [ -n "$OPT_FEATURES" ]; then local _raw _item _old_ifs _raw=$(printf '%s' "$OPT_FEATURES" | tr ',' ' ') _old_ifs="${IFS:- }" IFS=' ' for _item in $_raw; do [ -n "$_item" ] || continue if ! has_feature "$_item" "$_VALID_FEATURES"; then IFS="$_old_ifs" die "Unknown feature '$_item'. Valid: bs, bs-gpg, bs-server, bs-server-web, desktop" fi if ! has_feature "$_item" "$_feats"; then _feats="${_feats:+$_feats }$_item" fi done IFS="$_old_ifs" fi printf '%s' "$_feats" } # ── Main ─────────────────────────────────────────────────────────────────────── main() { parse_args "$@" # Skeleton mode: redirect install target to /etc/skel and skip per-user setup. if [ "$OPT_SKELETON" = "1" ]; then if [ "$(uname -s)" != "Linux" ]; then die "--skeleton is Linux-only (no /etc/skel on $(uname -s))" fi if [ "$(id -u)" != "0" ]; then die "--skeleton writes to /etc/skel and requires root. Re-run with sudo." fi BETTERSIGN_HOME="/etc/skel/.bettersign" BIN_DIR="/etc/skel/.bettersign/bin" OPT_MODIFY_PATH=0 OPT_INSTALL_SERVICE=0 fi printf "\n${BOLD}BetterSign Installer${NC}\n" printf "${DIM}https://bettersign.io${NC}\n\n" check_deps local _platform _arch _env _platform="$(detect_platform)" _arch="$(detect_arch)" _env="$(detect_environment)" # Resolve which features to install. resolve_features merges the cargo-style # defaults (unless --no-default-features) with the additive --features list # and validates every name. It either echoes a space-separated list or dies. local _features _features="$(resolve_features "$_env")" [ -n "$_features" ] || die "No features selected. Pass --features or omit --no-default-features." local _bin_dir="${BIN_DIR:-${BETTERSIGN_HOME}/bin}" mkdir -p "$_bin_dir" info "Platform: ${_platform}" info "Arch: ${_arch}" info "Environment: ${_env}" info "Version: ${INSTALL_VERSION}" info "Install to: ${_bin_dir}" info "Features: ${_features}" printf "\n" # Install each selected feature. Order matches the canonical feature list so # that `bs` always installs before its dependents. if has_feature "bs" "$_features"; then install_tool "bs" "$_platform" "$_arch" "$INSTALL_VERSION" "$_bin_dir" fi if has_feature "bs-gpg" "$_features"; then install_tool "bs-gpg" "$_platform" "$_arch" "$INSTALL_VERSION" "$_bin_dir" fi if has_feature "bs-server-web" "$_features"; then install_tool "bs-server-web" "$_platform" "$_arch" "$INSTALL_VERSION" "$_bin_dir" fi if has_feature "bs-server" "$_features"; then install_tool "bs-server" "$_platform" "$_arch" "$INSTALL_VERSION" "$_bin_dir" fi if has_feature "desktop" "$_features"; then printf "\n" install_desktop "$INSTALL_VERSION" "$_arch" fi local _installed_version _installed_version="$(resolve_installed_version "$_platform" "$_arch")" \ || die "Failed to resolve installed version from version.txt" [ -n "$_installed_version" ] || die "version.txt at ${BETTERSIGN_DIST_SERVER}/${_platform}/latest/${_arch}/version.txt is empty" write_version "$_installed_version" if [ "$OPT_SKELETON" = "1" ]; then write_skeleton_env_file else write_env_file "$_bin_dir" fi install_updater "$_bin_dir" printf "\n" configure_path "$_bin_dir" # Service setup: skeleton writes the unit template; normal installs activate it. if [ "$OPT_SKELETON" = "1" ]; then local _skel_server="" if has_feature "bs-server-web" "$_features"; then _skel_server="bs-server-web" elif has_feature "bs-server" "$_features"; then _skel_server="bs-server" fi if [ -n "$_skel_server" ]; then setup_skeleton "$_skel_server" else warn "--skeleton: no server feature selected; service unit not written." fi elif has_feature "bs-server-web" "$_features"; then setup_service "${_bin_dir}/bs-server-web" elif has_feature "bs-server" "$_features"; then setup_service "${_bin_dir}/bs-server" else info "No server feature selected — skipping service setup." fi printf "\n" bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" success "BetterSign ${_installed_version} installed successfully!" bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" printf "\n" if has_feature "bs" "$_features"; then printf " ${CYAN}bs${NC} --help\n" fi if has_feature "bs-server-web" "$_features"; then printf " ${CYAN}bs-server-web${NC} --help # API daemon + embedded browser UI\n" printf " Open ${CYAN}http://localhost:1999${NC} in your browser\n" fi if has_feature "bs-server" "$_features"; then printf " ${CYAN}bs-server${NC} --help # headless API daemon\n" fi printf " ${CYAN}bsup${NC} # update to latest\n" printf "\n" printf "To activate in your current shell without restarting:\n" printf " ${DIM}. \"\$HOME/.bettersign/env\"${NC}\n" printf "\n" printf "Or restart your shell to pick up the PATH change automatically.\n" printf "\n" printf "To uninstall: rm -rf %s\n" "$_bin_dir" printf "\n" } main "$@"