Interactive installer: prompt before replacing an existing install or overwriting an existing config, reading answers from /dev/tty so it works under 'curl ... | sh'. Falls back to the prior non-interactive contract when no terminal is available. MOVE_FORCE=1 skips all prompts; new MOVE_RESEED_CONFIG=1 reseeds the config unattended (old file kept as .bak). Fix: install.sh no longer wipes a working install before downloading. The tree is built in a staging dir and swapped into place only once complete, so a failed download/extract/build leaves the existing install intact.
372 lines
14 KiB
Bash
Executable File
372 lines
14 KiB
Bash
Executable File
#!/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: master.
|
|
# 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"
|
|
|
|
MOVE_VERSION="${MOVE_VERSION:-master}"
|
|
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
|
|
}
|
|
|
|
# --- 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 <<EOF
|
|
Error: bun is not installed.
|
|
Install it from https://bun.sh
|
|
(e.g. 'curl -fsSL https://bun.sh/install | bash')
|
|
then re-run this installer.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
BUN_VERSION=$(bun --version)
|
|
printf '==> Using bun %s\n' "$BUN_VERSION"
|
|
|
|
# --- 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" <<EOF
|
|
#!/usr/bin/env sh
|
|
exec bun "$INSTALL_DIR/src/move.ts" "\$@"
|
|
EOF
|
|
chmod +x "$WRAPPER"
|
|
|
|
# --- Seed user config file ---------------------------------------------------
|
|
#
|
|
# The defaults file shipped with the source tree (scripts/config.default.json)
|
|
# is also the single source of truth for the runtime defaults loaded by
|
|
# src/config.ts, so seeding a fresh user file from the same place keeps the
|
|
# CLI behavior and the user-visible config in sync.
|
|
#
|
|
# Policy: an existing user config is never overwritten *silently*. It is
|
|
# replaced only on an explicit answer to the prompt above or an explicit
|
|
# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak
|
|
# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive
|
|
# run -- leaves it untouched, so automation that reinstalls the software
|
|
# can't take a user's customizations down with it. The uninstaller follows
|
|
# the matching policy of never removing the config at all.
|
|
|
|
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
|
|
|
|
if [ ! -f "$SEED_SRC" ]; then
|
|
die "default config seed missing from install tree: $SEED_SRC"
|
|
fi
|
|
|
|
mkdir -p "$CONFIG_DIR"
|
|
if [ ! -e "$CONFIG_FILE" ]; then
|
|
cp "$SEED_SRC" "$CONFIG_FILE"
|
|
printf '==> 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"
|