#!/bin/sh # JellyoTmux installer. Sets up the tmux config, helper scripts, prerequisites, # TPM and all plugins in one shot. # # Remote (one-liner), from your own domain or GitHub, either works: # curl -fsSL https://jellyo.net/tmux-config.sh | sh # your domain # curl -fsSL https://raw.githubusercontent.com/jellyo-o/JellyoTmux/main/install.sh | sh # # From a clone: # git clone https://github.com/jellyo-o/JellyoTmux.git # cd JellyoTmux && ./install.sh # # Air-gapped, two steps. On a machine WITH network: # ./install.sh --bundle -> jellyotmux-bundle.tar.gz # copy that file across by whatever means you have, then: # tar xzf jellyotmux-bundle.tar.gz && cd jellyotmux-bundle && ./install.sh # # This script is host-agnostic: wherever it is served from, the config files # are pulled fresh from GitHub (REPO_SLUG below), so GitHub stays the single # source of truth. Point your domain path at the GitHub raw URL so it always # serves the latest. # # THREE THINGS IT SURVIVES WITHOUT # # sudo Everything except installing packages already happens inside # $HOME, so the whole config installs with no privileges at all. # --no-sudo skips package installation outright. That mode also # turns itself on when there is no way to escalate, including the # case where sudo exists but needs a password and there is no # terminal to ask on, which used to hang the curl one-liner. # # git The repo and the plugins are fetched as tarballs instead. A box # that cannot install git can therefore still install this. # # network --bundle packs the config AND every plugin into one tarball on a # connected machine. Installing from that bundle touches the network # zero times, and says so if anything would have needed it. # # Every run ends with a dependency report naming what is missing, what it # costs you, and how to fix it without root. # # --bundle[=PATH] build an offline bundle instead of installing # --with-fzf include this machine's fzf binary in the bundle # --offline never touch the network # --no-sudo never attempt a privileged command # --user-fzf install fzf into ~/.fzf without root (downloads a # prebuilt binary; opt-in, never automatic) # --yes, -y skip the confirmation prompt # --help, -h show this and exit # # Environment equivalents: JELLYOTMUX_NO_SUDO, JELLYOTMUX_USER_FZF, # JELLYOTMUX_OFFLINE, JELLYOTMUX_YES. REPO_SLUG="jellyo-o/JellyoTmux" REPO_URL="https://github.com/$REPO_SLUG.git" CLONE_DIR="$HOME/.jellyotmux" PLUGIN_DIR="$HOME/.tmux/plugins" BUNDLE_MARKER=".jellyotmux-bundle" say() { printf '==> %s\n' "$1"; } warn() { printf ' ! %s\n' "$1" >&2; } have() { command -v "$1" >/dev/null 2>&1; } # Is there a terminal we can actually talk to? This has to OPEN /dev/tty, not # stat it. `[ -r /dev/tty ]` only asks whether the device file's permissions # allow reading, which they do (crw-rw-rw-) even for a process with no # controlling terminal, where the open then fails with ENXIO. Getting this # wrong meant handing the job to sudo in exactly the detached case that cannot # answer a password prompt. # # The open is done inside a SUBSHELL for a POSIX reason: a redirection that # fails on a special built-in makes a non-interactive shell exit on the spot. # `:` is a special built-in, so `{ : /dev/null; } usage() { cat <<'USAGE' JellyoTmux installer ./install.sh install (fetches what it needs) ./install.sh --bundle build jellyotmux-bundle.tar.gz for an air-gapped machine, then install nothing ./install.sh --bundle=/tmp/x.tgz build the bundle at a chosen path Options --bundle[=PATH] build an offline bundle instead of installing --with-fzf include this machine's fzf binary in the bundle (the target must be the same OS and architecture) --offline never touch the network; use only what is already here --no-sudo never attempt a privileged command --user-fzf install fzf into ~/.fzf without root --yes, -y skip the confirmation prompt --help, -h this text Installing from a bundle needs no flags: unpack it and run ./install.sh from inside. It detects the bundle, works entirely offline, and copies itself to ~/.jellyotmux so you can delete the unpacked folder afterwards. USAGE } # --------------------------------------------------------------------------- # 0. Flags. # --------------------------------------------------------------------------- NO_SUDO=""; USER_FZF=""; OFFLINE=""; BUNDLE=""; BUNDLE_OUT=""; WITH_FZF="" [ -n "$JELLYOTMUX_NO_SUDO" ] && NO_SUDO=1 [ -n "$JELLYOTMUX_USER_FZF" ] && USER_FZF=1 [ -n "$JELLYOTMUX_OFFLINE" ] && OFFLINE=1 for arg in "$@"; do case "$arg" in --bundle) BUNDLE=1 ;; --bundle=*) BUNDLE=1; BUNDLE_OUT=${arg#--bundle=} ;; --with-fzf) WITH_FZF=1 ;; --offline) OFFLINE=1 ;; --no-sudo) NO_SUDO=1 ;; --user-fzf) USER_FZF=1 ;; --yes|-y) JELLYOTMUX_YES=1 ;; --help|-h) usage; exit 0 ;; *) warn "unknown option: $arg (try --help)" ;; esac done # --------------------------------------------------------------------------- # 1. Decide how, or whether, we can escalate. # # `sudo -n true` is the important test: it asks whether sudo would run # WITHOUT prompting. Plain `sudo apt-get` inside `curl | sh` otherwise stops # dead on a password prompt, which reads as a hang because the summary has # already scrolled by. If sudo would prompt and there is a terminal to # prompt on, that is fine and we use it. With no terminal, drop to no-sudo # rather than hang. # --------------------------------------------------------------------------- SUDO="" if [ -n "$NO_SUDO" ]; then PRIV="no-sudo mode requested" elif [ "$(id -u)" -eq 0 ]; then PRIV="running as root" elif ! have sudo; then NO_SUDO=1; PRIV="no sudo on PATH" elif sudo -n true >/dev/null 2>&1; then SUDO="sudo"; PRIV="passwordless sudo" elif have_tty; then SUDO="sudo"; PRIV="sudo will ask for your password" else NO_SUDO=1; PRIV="sudo needs a password and there is no terminal to ask on" fi # --------------------------------------------------------------------------- # 2. Fetching, and doing without it. # # A box that cannot install git cannot be required to have git. GitHub # serves every repo as a tarball at codeload//tar.gz/HEAD, which # resolves to the default branch, so curl (or wget) plus tar is enough. The # cost is that a tarball copy has no .git and cannot self-update: re-running # this installer refetches it instead. # # In offline mode nothing here is even attempted, so a machine with no route # out fails fast and legibly instead of waiting on a DNS timeout. # --------------------------------------------------------------------------- dl() { # $1 = url, streamed to stdout if have curl; then curl -fsSL "$1" elif have wget; then wget -qO- "$1" else return 1 fi } net_ok() { # can we, and are we allowed to, reach the network at all [ -n "$OFFLINE" ] && return 1 have curl || have wget } fetch_tarball() { # $1 = owner/repo, $2 = destination directory net_ok || return 1 have tar || return 1 _tmp=$(mktemp -d 2>/dev/null) || return 1 if dl "https://codeload.github.com/$1/tar.gz/HEAD" \ | tar -xz -C "$_tmp" --strip-components=1 2>/dev/null; then rm -rf "$2" && mkdir -p "$(dirname "$2")" && mv "$_tmp" "$2" && return 0 fi rm -rf "$_tmp" return 1 } # --------------------------------------------------------------------------- # 3. Where the config files come from. A bundle wins over everything, because # if one is present that is plainly what the user meant to install. # --------------------------------------------------------------------------- SRC="" FROM_BUNDLE="" SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0" 2>/dev/null)" 2>/dev/null && pwd) resolve_src() { if [ -n "$SELF_DIR" ] && [ -f "$SELF_DIR/$BUNDLE_MARKER" ]; then SRC="$SELF_DIR"; FROM_BUNDLE=1; OFFLINE=1 say "Installing from bundle: $SRC" return 0 fi if [ -n "$SELF_DIR" ] && [ -f "$SELF_DIR/tmux/tmux-help.sh" ] && [ -f "$SELF_DIR/.tmux.conf" ]; then SRC="$SELF_DIR" say "Installing from local checkout: $SRC" return 0 fi if [ -f "$CLONE_DIR/$BUNDLE_MARKER" ]; then SRC="$CLONE_DIR"; FROM_BUNDLE=1; OFFLINE=1 say "Using the bundle already unpacked at $CLONE_DIR" return 0 fi if [ -d "$CLONE_DIR/.git" ] && have git && [ -z "$OFFLINE" ]; then say "Updating existing clone: $CLONE_DIR" git -C "$CLONE_DIR" pull --ff-only >/dev/null 2>&1 || warn "pull failed; using existing copy" SRC="$CLONE_DIR"; return 0 fi if [ -f "$CLONE_DIR/.tmux.conf" ]; then say "Using existing copy: $CLONE_DIR" SRC="$CLONE_DIR"; return 0 fi if [ -n "$OFFLINE" ]; then warn "offline, and no config found here, in a bundle, or at $CLONE_DIR" return 1 fi if have git; then say "Cloning $REPO_URL -> $CLONE_DIR" git clone --depth 1 "$REPO_URL" "$CLONE_DIR" >/dev/null 2>&1 && { SRC="$CLONE_DIR"; return 0; } fi say "Fetching $REPO_SLUG as a tarball (no git) -> $CLONE_DIR" fetch_tarball "$REPO_SLUG" "$CLONE_DIR" && { SRC="$CLONE_DIR"; return 0; } warn "could not obtain the config: need git, or curl/wget plus tar" return 1 } # --------------------------------------------------------------------------- # 4. Plugins. A bundle carries its own copies, so this never needs the network # when installing from one. # --------------------------------------------------------------------------- PLUGINS_OK=1 plugin_list() { # every @plugin 'owner/repo' in the config, TPM first printf 'tmux-plugins/tpm\n' grep -E "^[[:space:]]*set(-option)?[[:space:]]+-g[[:space:]]+@plugin[[:space:]]" \ "$SRC/.tmux.conf" 2>/dev/null \ | sed -E "s/.*@plugin[[:space:]]+['\"]([^'\"]+)['\"].*/\1/" \ | grep -v '^tmux-plugins/tpm$' } get_plugin() { # $1 = owner/repo name=$(basename "$1") dest="$PLUGIN_DIR/$name" # Already present and populated: leave it alone, or refresh it if it is a # git checkout and we are allowed out. if [ -d "$dest/.git" ] && have git && [ -z "$OFFLINE" ]; then git -C "$dest" pull --ff-only >/dev/null 2>&1 || true return 0 fi if [ -d "$dest" ] && [ -n "$(ls -A "$dest" 2>/dev/null)" ]; then return 0 fi # A bundle ships the plugins beside the config: copy, never fetch. if [ -n "$FROM_BUNDLE" ] && [ -d "$SRC/plugins/$name" ]; then say " + $name (from bundle)" mkdir -p "$PLUGIN_DIR" && cp -a "$SRC/plugins/$name" "$dest" && return 0 fi if [ -n "$OFFLINE" ]; then warn "offline: cannot install plugin $1" PLUGINS_OK=""; return 1 fi say " + $1" if have git; then git clone --depth 1 "https://github.com/$1" "$dest" >/dev/null 2>&1 && return 0 fi fetch_tarball "$1" "$dest" && return 0 warn "failed to fetch $1" PLUGINS_OK="" return 1 } install_plugins() { mkdir -p "$PLUGIN_DIR" # How many the config asks for, so a short list can be spotted below. Every # @plugin line counts, TPM's own included, which is why plugin_list emits TPM # unconditionally: the two numbers are then directly comparable. declared=$(grep -c "@plugin" "$SRC/.tmux.conf" 2>/dev/null) || declared=0 [ -r "$SRC/.tmux.conf" ] || warn "cannot read $SRC/.tmux.conf" # A here-document, not a pipe: a pipe would run the loop in a subshell and # neither PLUGINS_OK nor resolved would survive it. resolved=0 while IFS= read -r repo; do [ -n "$repo" ] || continue resolved=$((resolved + 1)) get_plugin "$repo" done </dev/null done say "Staging plugins" PLUGIN_DIR="$root/plugins" install_plugins if [ -n "$WITH_FZF" ]; then fzf_bin=$(command -v fzf 2>/dev/null) if [ -n "$fzf_bin" ]; then mkdir -p "$root/bin" && cp -L "$fzf_bin" "$root/bin/fzf" \ && say "Staging fzf binary ($(uname -s)/$(uname -m))" else warn "--with-fzf given but no fzf on this machine to copy" fi fi { printf 'JellyoTmux offline bundle\n' printf 'built: %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC' 2>/dev/null)" printf 'built on: %s %s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)" printf 'install with: ./install.sh\n' } > "$root/$BUNDLE_MARKER" # Read the manifest BEFORE the staging directory is removed. staged_plugins=$(ls "$root/plugins" 2>/dev/null | tr '\n' ' ') staged_fzf=no; [ -x "$root/bin/fzf" ] && staged_fzf=yes mkdir -p "$(dirname "$BUNDLE_OUT")" 2>/dev/null if tar -czf "$BUNDLE_OUT" -C "$stage" jellyotmux-bundle; then rm -rf "$stage" printf '\n' say "Bundle written: $BUNDLE_OUT" printf ' size: %s\n' "$(du -h "$BUNDLE_OUT" 2>/dev/null | cut -f1)" printf ' plugins: %s\n' "$staged_plugins" printf ' fzf: %s\n' "$staged_fzf" printf '\n On the offline machine:\n' printf ' tar xzf %s\n' "$(basename "$BUNDLE_OUT")" printf ' cd jellyotmux-bundle && ./install.sh\n\n' [ -z "$PLUGINS_OK" ] && warn "some plugins are missing from this bundle, see above" exit 0 fi rm -rf "$stage" warn "could not write $BUNDLE_OUT" exit 1 fi # --------------------------------------------------------------------------- # 6. Confirmation (skip with --yes / JELLYOTMUX_YES=1). # --------------------------------------------------------------------------- if [ -z "$JELLYOTMUX_YES" ]; then printf '\n JellyoTmux installer. This will:\n' printf ' - link ~/.tmux.conf + helper scripts into ~/.tmux/\n' if [ -n "$NO_SUDO" ]; then printf ' - install NO packages (%s)\n' "$PRIV" printf ' - report which features that costs you\n' else printf ' - install any missing prereqs (tmux, git, fzf, less, sqlite3)\n' printf ' using %s (%s)\n' "${SUDO:-current privileges}" "$PRIV" fi printf ' - install TPM + plugins (tmux-resurrect, tmux-continuum)\n' [ -n "$OFFLINE" ] && printf ' - touch the network zero times (offline mode)\n' printf ' Everything lands in $HOME. It does NOT start tmux or restore sessions.\n\n' if have_tty; then # read the key even though stdin is the curl pipe printf ' Proceed? [y/N] ' read ans /dev/null 2>&1 ;; # brew must not run under sudo *) $SUDO $PKG "$2" >/dev/null 2>&1 ;; esac have "$1" } if [ -n "$NO_SUDO" ]; then say "Skipping package installation ($PRIV)" else install_pkg git git install_pkg tmux tmux install_pkg fzf fzf install_pkg less less install_pkg xclip xclip # only used when tmux runs locally under X install_pkg sqlite3 sqlite3 # agent-state markers; without it they go blank fi # --------------------------------------------------------------------------- # 8. Source files. # --------------------------------------------------------------------------- [ -n "$SRC" ] || resolve_src || exit 1 # A bundle is unpacked wherever it happened to land, often /tmp. The links # below point AT the source, so copy the bundle somewhere permanent first and # link from there: otherwise deleting the unpacked folder silently breaks every # helper script. A git checkout is left where it is on purpose, so that editing # the repo takes effect immediately. if [ -n "$FROM_BUNDLE" ] && [ "$SRC" != "$CLONE_DIR" ]; then say "Copying the bundle to $CLONE_DIR (so you can delete the unpacked folder)" mkdir -p "$CLONE_DIR" && cp -a "$SRC"/. "$CLONE_DIR"/ && SRC="$CLONE_DIR" fi # --------------------------------------------------------------------------- # 9. fzf without root. From a bundle built with --with-fzf the binary is right # there; otherwise --user-fzf fetches the official prebuilt one. Opt-in in # both cases, since it means putting a binary on your PATH. # --------------------------------------------------------------------------- install_user_fzf() { if [ -x "$SRC/bin/fzf" ]; then say "Installing the bundled fzf into ~/.local/bin" mkdir -p "$HOME/.local/bin" && cp -a "$SRC/bin/fzf" "$HOME/.local/bin/fzf" \ && chmod +x "$HOME/.local/bin/fzf" elif [ -n "$USER_FZF" ]; then say "Installing fzf into ~/.fzf (no root)" if [ -d "$HOME/.fzf/.git" ] && have git && [ -z "$OFFLINE" ]; then git -C "$HOME/.fzf" pull --ff-only >/dev/null 2>&1 || true elif have git && [ -z "$OFFLINE" ]; then git clone --depth 1 https://github.com/junegunn/fzf "$HOME/.fzf" >/dev/null 2>&1 \ || warn "could not clone fzf" elif [ ! -d "$HOME/.fzf" ]; then fetch_tarball junegunn/fzf "$HOME/.fzf" || { warn "could not fetch fzf"; return 1; } fi [ -x "$HOME/.fzf/install" ] && "$HOME/.fzf/install" --bin >/dev/null 2>&1 if [ -x "$HOME/.fzf/bin/fzf" ]; then mkdir -p "$HOME/.local/bin" ln -sfn "$HOME/.fzf/bin/fzf" "$HOME/.local/bin/fzf" else warn "fzf binary download failed; see https://github.com/junegunn/fzf" return 1 fi else return 1 fi PATH="$HOME/.local/bin:$PATH"; export PATH say " fzf -> ~/.local/bin/fzf" } have fzf || install_user_fzf # --------------------------------------------------------------------------- # 10. Link config + helper scripts into place (symlinks, so an update to the # source takes effect immediately). # --------------------------------------------------------------------------- say "Linking ~/.tmux.conf and ~/.tmux/*.sh" mkdir -p "$HOME/.tmux" ln -sfn "$SRC/.tmux.conf" "$HOME/.tmux.conf" for f in "$SRC"/tmux/*.sh; do chmod +x "$f" 2>/dev/null ln -sfn "$f" "$HOME/.tmux/$(basename "$f")" done # --------------------------------------------------------------------------- # 11. TPM + plugins, fetched DIRECTLY (this script never starts a tmux server). # # Why not TPM's install_plugins? It needs a running server that has loaded # the config, and this config sets @continuum-restore 'on'. Starting a # fresh server would make tmux-continuum restore every saved session and # re-spawn all their processes, a big CPU and memory spike. Putting the # files in ~/.tmux/plugins/ is exactly what TPM does, minus the # server. # --------------------------------------------------------------------------- say "Installing TPM + plugins (direct, no tmux server started)" install_plugins # --------------------------------------------------------------------------- # 12. Dependency check. Always printed, because "installed successfully" is # misleading when half the keybindings have no binary behind them. # --------------------------------------------------------------------------- MISSING_REQ=0 dep() { # $1 = command, $2 = req|opt, $3 = what its absence costs, $4 = fix _p=$(command -v "$1" 2>/dev/null) if [ -n "$_p" ]; then printf ' %-8s OK %s\n' "$1" "$_p" return 0 fi if [ "$2" = req ]; then printf ' %-8s MISSING %s\n' "$1" "$3" MISSING_REQ=$((MISSING_REQ + 1)) else printf ' %-8s missing %s\n' "$1" "$3" fi [ -n "$4" ] && printf ' %-8s fix: %s\n' '' "$4" return 1 } fzf_fix='./install.sh --user-fzf (no root)' [ -n "$OFFLINE" ] && fzf_fix='rebuild the bundle with --with-fzf on a connected machine' printf '\n' say "Dependency check" dep tmux req 'nothing runs without it' \ 'needs a package manager, there is no practical user-level install' dep fzf req 'prefix + j / a / g / . / X will not work' "$fzf_fix" dep git opt 'window labels show the directory name, not the git branch' '' dep sqlite3 opt 'agent-state markers stay blank, everything else is fine' \ 'drop a static sqlite3 binary into ~/.local/bin' dep less opt 'prefix + ? still opens but cannot scroll' '' dep xclip opt 'no X clipboard, though OSC 52 still reaches a local one' '' if [ -z "$PLUGINS_OK" ]; then printf ' %-8s MISSING session persistence is off (resurrect/continuum)\n' 'plugins' printf ' %-8s fix: %s\n' '' 'build a bundle on a connected machine: ./install.sh --bundle' MISSING_REQ=$((MISSING_REQ + 1)) fi if ! have git; then printf ' %-8s note no git, so TPM cannot self-update; re-run this\n' '' printf ' %-8s installer to refresh instead\n' '' fi case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) if [ -x "$HOME/.local/bin/fzf" ]; then printf ' %-8s note ~/.local/bin is not on your PATH; add it or tmux\n' '' printf ' %-8s will not find the fzf that was just installed\n' '' fi ;; esac # --------------------------------------------------------------------------- # 13. Finish. Intentionally NO tmux commands here (no source-file, no # new-session), so nothing can trigger continuum-restore. # --------------------------------------------------------------------------- printf '\n' if [ "$MISSING_REQ" -gt 0 ]; then say "Done, with $MISSING_REQ thing(s) missing. See the fixes above." else say "Done." fi printf '\n' printf 'Next steps:\n' printf ' - Start tmux, plugins load automatically.\n' printf ' - Already inside tmux? Reload with: prefix + r (prefix is Ctrl-a)\n' printf ' - Press prefix + ? for the cheat-sheet.\n'