#!/usr/bin/env sh # # install.sh - End-user installer for the `move` CLI. # # Curl-pipe ready: # # curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh # # What it does: # 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship. # 2. Require Bun; fail with a clear hint if missing (no auto-install). # 3. Resolve XDG-compliant install paths. # 4. Detect an existing install and, on a terminal, ask before replacing it. # 5. Download and build in a temp staging dir; swap it over the install # dir only once it's complete, so a failed run can't destroy a working # install. # 6. `bun install --production` (skips devDependencies). # 7. Drop a small wrapper script as `move` on the user's bin dir. # 8. Seed the user's config file with defaults if one doesn't already exist # at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it. # 9. Verify PATH, surface macOS Accessibility hint, print final status. # # Interactivity: # When a controlling terminal is available, an existing install is never # replaced without asking, and an existing config is never overwritten # without asking. Both questions are put up front, before anything is # downloaded or deleted, so declining costs nothing. With no terminal # (CI, cron, container build) the script falls back to its historical # non-interactive contract: an identical version is a no-op, a different # version is replaced, and the config is left alone. # # Env vars (all optional): # MOVE_VERSION Branch or tag to install. Default: the newest tag # published to the repo, falling back to the master # branch if that can't be determined. # MOVE_FORCE Set to 1 to skip every prompt and reinstall # unconditionally. Does not touch the user config. # MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the # shipped defaults without asking. The previous file # is saved alongside it as config.json.bak. # XDG_DATA_HOME Source install root (default $HOME/.local/share). # Final source location is $XDG_DATA_HOME/move. # XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin). # Final binary location is $XDG_BIN_HOME/move. # XDG_CONFIG_HOME User config root (default $HOME/.config). # Default config file is $XDG_CONFIG_HOME/move/config.json. # # POSIX sh; no bashisms. Note the absence of `local`: helper functions use # `_`-prefixed globals, which POSIX sh leaves us with. set -eu REPO_OWNER="nokeo08" REPO_NAME="Move" GITEA_HOST="gitea.cahlen.com" # Left empty when unset so it can be resolved to the latest tag once the # prerequisite tools are confirmed present (see "Resolve version" below). MOVE_VERSION="${MOVE_VERSION:-}" MOVE_FORCE="${MOVE_FORCE:-0}" MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}" INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move" BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move" CONFIG_FILE="$CONFIG_DIR/config.json" WRAPPER="$BIN_DIR/move" die() { printf 'Error: %s\n' "$1" >&2 exit 1 } # Refuse to operate on a directory that points somewhere catastrophic. # The XDG_* env vars are broadly-scoped and the user could set them to # anything; our paths always culminate in `.../move`, but a malformed # XDG var could still resolve to something like '/move' which we don't # want to `rm -rf` or otherwise mass-write into. assert_safe_dir() { case "$1" in '' | '/' | "$HOME" | "$HOME/" | '/move') die "refusing to operate on '$1' (too broad)" ;; esac } # Print the newest tag name published to the repo (e.g. v1.3.2), or return # non-zero if it can't be determined. The Gitea tags API lists newest first, # so with ?limit=1 the sole entry is the latest tag; grep+sed pull its "name" # without a jq dependency. Any failure -- offline, API error, no tags, a # missing grep/sed -- collapses to a non-zero return and lets the caller fall # back to the master branch. resolve_latest_tag() { _tags_url="https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/tags?limit=1" _json=$(curl -fsSL --max-time 10 "$_tags_url" 2>/dev/null) || return 1 _tag=$(printf '%s' "$_json" | grep -o '"name":"[^"]*"' | head -n 1 | sed 's/.*:"//; s/"$//') [ -n "$_tag" ] || return 1 printf '%s\n' "$_tag" } # --- Interactive prompt support ---------------------------------------------- # # The documented entry point is `curl -fsSL ... | sh`, which means stdin is # the *script source itself*. Reading a prompt answer from stdin would # consume the rest of the program and truncate execution mid-run, so every # prompt reads from /dev/tty directly. # # Detecting whether that's possible needs a real open(2) attempt. A `[ -r # /dev/tty ]` test is not enough: the device node exists and is mode 0666 # even in contexts with no controlling terminal (cron, CI, container # builds), where opening it fails with ENXIO. The probe runs in a subshell # because a redirection failure on `exec` -- a special built-in -- exits a # non-interactive shell outright under POSIX. if (: >/dev/tty) 2>/dev/null; then INTERACTIVE=1 else INTERACTIVE=0 fi # confirm PROMPT DEFAULT -> 0 for yes, 1 for no. # # DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the # loop can't spin forever against a closed terminal. Prompts are written to # /dev/tty rather than stdout so they stay visible when the caller redirects # our output. confirm() { _prompt="$1" _default="$2" case "$_default" in y) _hint='[Y/n]' ;; *) _hint='[y/N]' ;; esac while :; do printf '%s %s ' "$_prompt" "$_hint" > /dev/tty if ! IFS= read -r _reply < /dev/tty; then printf '\n' > /dev/tty _reply='' fi if [ -z "$_reply" ]; then _reply="$_default" fi case "$_reply" in [yY] | [yY][eE][sS]) return 0 ;; [nN] | [nN][oO]) return 1 ;; *) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;; esac done } # --- Prerequisite tools ------------------------------------------------------ for tool in curl tar mktemp; do if ! command -v "$tool" >/dev/null 2>&1; then die "$tool is required (not found in PATH)" fi done # --- Platform detection ------------------------------------------------------ OS=$(uname -s) ARCH=$(uname -m) case "$OS" in Darwin|Linux) ;; *) die "unsupported OS '$OS' (move supports macOS and Linux)" ;; esac case "$ARCH" in arm64|aarch64|x86_64|amd64) ;; *) die "unsupported architecture '$ARCH'" ;; esac # --- Bun check (hard fail; no auto-install) ---------------------------------- if ! command -v bun >/dev/null 2>&1; then cat >&2 < Using bun %s\n' "$BUN_VERSION" # --- Resolve version to install ---------------------------------------------- # # With no explicit MOVE_VERSION, default to the newest published tag so the # plain one-liner installs a real release and reports its version (e.g. # v1.3.2) rather than tracking the moving `master` branch. If the lookup # fails -- offline, API unreachable, or no tags yet -- fall back to master, # preserving the historical behavior instead of aborting. if [ -z "$MOVE_VERSION" ]; then if MOVE_VERSION=$(resolve_latest_tag); then printf '==> Latest release is %s\n' "$MOVE_VERSION" else MOVE_VERSION=master printf '==> Could not determine latest release; installing from master\n' >&2 fi fi # --- Existing install check -------------------------------------------------- # # Both questions this script can ask are asked here, before anything is # downloaded, deleted, or written. Declining therefore costs the user # nothing, and no prompt appears minutes into a `bun install`. assert_safe_dir "$INSTALL_DIR" assert_safe_dir "$CONFIG_DIR" INSTALLED_VERSION='' if [ -f "$INSTALL_DIR/.installed-version" ]; then INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '') fi # Look wider than the version marker: a half-finished or hand-edited install # can leave a tree or a wrapper behind without one, and steamrolling that # silently is precisely what this check exists to prevent. FOUND_EXISTING=0 if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then FOUND_EXISTING=1 fi # Decided here, applied at the end -- the seed file it copies from only # exists once the tarball has been extracted. RESEED_CONFIG="$MOVE_RESEED_CONFIG" if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then if [ -n "$INSTALLED_VERSION" ]; then printf '==> Found an existing move install (%s) at %s\n' \ "$INSTALLED_VERSION" "$INSTALL_DIR" else printf '==> Found an existing move install at %s (version unknown)\n' \ "$INSTALL_DIR" fi if [ "$INTERACTIVE" = "1" ]; then # The defaults below are chosen so that a bare Enter reproduces # exactly what this script did before it learned to ask: skip when # the version is identical, replace when it differs. if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?" REPLACE_DEFAULT=n else REPLACE_PROMPT="Replace it with $MOVE_VERSION?" REPLACE_DEFAULT=y fi if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then printf 'Leaving the existing install alone. Nothing was changed.\n' exit 0 fi else # Nowhere to ask, so fall back to the historical contract. if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER" printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n' exit 0 fi printf '==> No terminal available; replacing %s with %s\n' \ "${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION" fi fi if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] && [ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then printf '==> A config file already exists at %s\n' "$CONFIG_FILE" if confirm 'Overwrite it with the shipped defaults?' n; then RESEED_CONFIG=1 fi fi # --- Stage, download, build -------------------------------------------------- # # Everything is assembled in a temp staging dir first; the existing install # is removed only once the staged tree is fully built and ready to swap in. # A failed download, extract, or `bun install` therefore leaves a working # install untouched -- unlike the old flow, which wiped INSTALL_DIR before # the download even started and left nothing behind on any failure. mkdir -p "$BIN_DIR" DATA_ROOT=$(dirname "$INSTALL_DIR") mkdir -p "$DATA_ROOT" TARBALL_TMP=$(mktemp) || die "could not create temp file" # Stage on the same filesystem as INSTALL_DIR so the final swap is a rename, # not a cross-device copy. STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") || { rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; } # On any exit, clean up the tarball and any leftover staging dir. After a # successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op. cleanup() { rm -f "$TARBALL_TMP" rm -rf "$STAGE_DIR" } trap cleanup EXIT INT TERM TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz" printf '==> Downloading %s\n' "$TARBALL_URL" if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)" fi printf '==> Extracting source\n' if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then die "could not extract tarball from $TARBALL_URL" fi printf '==> Installing runtime dependencies (bun install --production)\n' (cd "$STAGE_DIR" && bun install --production) # Sanity-check the staged tree before we disturb the existing install: a # truncated or wrong tarball that's missing the config seed should fail here, # while the old install is still intact and swappable-out. if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)" fi # Record the version inside the staged tree so the install is self-consistent # the instant it lands. printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version" # --- Swap staged tree into place --------------------------------------------- # # The only destructive step, kept as late as possible: the window where # INSTALL_DIR is absent is just this rm + rename, not the whole build. assert_safe_dir "$INSTALL_DIR" printf '==> Installing to %s\n' "$INSTALL_DIR" rm -rf "$INSTALL_DIR" if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then die "could not move staged install into place at $INSTALL_DIR" fi # --- Drop the wrapper -------------------------------------------------------- printf '==> Writing wrapper to %s\n' "$WRAPPER" cat > "$WRAPPER" < Wrote default config to %s\n' "$CONFIG_FILE" elif [ "$RESEED_CONFIG" = "1" ]; then cp "$CONFIG_FILE" "$CONFIG_FILE.bak" cp "$SEED_SRC" "$CONFIG_FILE" printf '==> Reseeded %s (previous file saved as %s)\n' \ "$CONFIG_FILE" "$CONFIG_FILE.bak" else printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE" fi # --- PATH sanity check ------------------------------------------------------- case ":$PATH:" in *":$BIN_DIR:"*) BIN_ON_PATH=1 ;; *) BIN_ON_PATH=0 ;; esac if [ "$BIN_ON_PATH" = "0" ]; then printf '\nNote: %s is not on your PATH. Add this to your shell rc:\n' "$BIN_DIR" printf ' export PATH="%s:$PATH"\n' "$BIN_DIR" fi # --- macOS Accessibility hint ------------------------------------------------ if [ "$OS" = "Darwin" ]; then printf '\nNote: macOS will prompt for Accessibility permission on first mouse move.\n' printf ' Grant it under System Settings > Privacy & Security > Accessibility.\n' fi # --- Final message ----------------------------------------------------------- printf '\nInstalled move %s at %s.\n' "$MOVE_VERSION" "$WRAPPER" printf "Run 'move --help' to get started.\n"