|
#!/usr/bin/env bash |
|
# Installer for the dev-tools-ubuntu list: |
|
# https://gist.github.com/Magnus167/b0a21c26a8eea95df8ee51c4e275316a |
|
# |
|
# Strategy: |
|
# Phase 1 (serial): apt repo setup + single apt-get install for system pkgs. |
|
# Phase 2 (serial): Docker (uses apt internally, can't share apt's lock). |
|
# Phase 3 (parallel): every remaining tool launches as its own background job. |
|
# Chains with a dependency (rust->jnv, uv->{harlequin,visidata}) |
|
# run inside a single subshell so the dependent step waits |
|
# only on its own prerequisite, not the whole world. |
|
# |
|
# Targets: Debian/Ubuntu on x86_64 or aarch64. |
|
# Idempotent: each step skips if the binary is already on PATH. |
|
|
|
set -uo pipefail |
|
|
|
LOG_DIR="$(mktemp -d -t devtools-install-XXXXXX)" |
|
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" |
|
mkdir -p "$BIN_DIR" |
|
|
|
echo "==> devtools installer" |
|
echo " logs: $LOG_DIR" |
|
echo " bindir: $BIN_DIR" |
|
echo |
|
|
|
# ---- arch detection ---- |
|
case "$(uname -m)" in |
|
x86_64) DEB_ARCH=amd64; GO_ARCH=amd64; UNAME_ARCH=x86_64 ;; |
|
aarch64|arm64) DEB_ARCH=arm64; GO_ARCH=arm64; UNAME_ARCH=aarch64 ;; |
|
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; |
|
esac |
|
|
|
SUDO="" |
|
if [ "${EUID:-$(id -u)}" -ne 0 ] && command -v sudo >/dev/null 2>&1; then |
|
SUDO="sudo" |
|
fi |
|
|
|
# Ensure curl exists before we lean on it everywhere. |
|
if ! command -v curl >/dev/null 2>&1; then |
|
$SUDO apt-get update -y |
|
$SUDO DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates |
|
fi |
|
|
|
# ---- background job tracking ---- |
|
declare -a PIDS=() |
|
declare -a NAMES=() |
|
|
|
launch() { |
|
# launch <name> <function-name> |
|
local name="$1"; local fn="$2" |
|
( |
|
echo "[$name] start" |
|
if "$fn" >"$LOG_DIR/$name.log" 2>&1; then |
|
echo "[$name] ok" |
|
else |
|
echo "[$name] FAILED (see $LOG_DIR/$name.log)" |
|
exit 1 |
|
fi |
|
) & |
|
PIDS+=("$!") |
|
NAMES+=("$name") |
|
} |
|
|
|
wait_all() { |
|
local fail=0 |
|
for i in "${!PIDS[@]}"; do |
|
if ! wait "${PIDS[$i]}"; then |
|
fail=1 |
|
fi |
|
done |
|
return $fail |
|
} |
|
|
|
# =================================================================== |
|
# Phase 1: apt repos + system packages (serial, single apt transaction) |
|
# =================================================================== |
|
echo "==> phase 1: apt repos + system packages" |
|
|
|
# GitHub CLI repo (so gh comes from upstream, not stale distro pkg). |
|
if [ ! -f /etc/apt/sources.list.d/github-cli.list ]; then |
|
$SUDO mkdir -p -m 755 /etc/apt/keyrings |
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ |
|
| $SUDO tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null |
|
$SUDO chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg |
|
echo "deb [arch=$DEB_ARCH signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ |
|
| $SUDO tee /etc/apt/sources.list.d/github-cli.list >/dev/null |
|
fi |
|
|
|
$SUDO apt-get update -y |
|
$SUDO DEBIAN_FRONTEND=noninteractive apt-get install -y \ |
|
git \ |
|
python3 python3-pip python3-venv \ |
|
ripgrep fzf jq \ |
|
gh \ |
|
curl wget ca-certificates gnupg tar unzip xz-utils \ |
|
build-essential pkg-config libssl-dev |
|
|
|
# =================================================================== |
|
# Phase 2: Docker (serial; its install script also calls apt-get) |
|
# =================================================================== |
|
echo "==> phase 2: docker" |
|
if command -v docker >/dev/null 2>&1; then |
|
echo "[docker] already installed" |
|
else |
|
if curl -fsSL https://get.docker.com | $SUDO sh >"$LOG_DIR/docker.log" 2>&1; then |
|
echo "[docker] ok" |
|
else |
|
echo "[docker] FAILED (see $LOG_DIR/docker.log)" |
|
fi |
|
fi |
|
|
|
# =================================================================== |
|
# Phase 3: parallel installers |
|
# =================================================================== |
|
echo "==> phase 3: parallel installers" |
|
|
|
# ---- helpers ---- |
|
gh_latest_tag() { |
|
# $1 = "owner/repo"; prints tag without leading 'v' |
|
curl -fsSL "https://api.github.com/repos/$1/releases/latest" \ |
|
| grep -Po '"tag_name": "v?\K[^"]*' |
|
} |
|
|
|
# ---- rust (rustup) ---- |
|
install_rust() { |
|
if [ -x "$HOME/.cargo/bin/cargo" ] || command -v cargo >/dev/null 2>&1; then |
|
echo "rust already installed"; return 0 |
|
fi |
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ |
|
| sh -s -- -y --no-modify-path --default-toolchain stable |
|
} |
|
|
|
install_jnv() { |
|
local cargo="$HOME/.cargo/bin/cargo" |
|
[ -x "$cargo" ] || cargo="$(command -v cargo || true)" |
|
[ -n "$cargo" ] || { echo "no cargo found"; return 1; } |
|
if command -v jnv >/dev/null 2>&1 || [ -x "$HOME/.cargo/bin/jnv" ]; then |
|
echo "jnv already installed"; return 0 |
|
fi |
|
"$cargo" install jnv |
|
} |
|
|
|
# ---- uv ---- |
|
install_uv() { |
|
if [ -x "$HOME/.local/bin/uv" ] || command -v uv >/dev/null 2>&1; then |
|
echo "uv already installed"; return 0 |
|
fi |
|
curl -LsSf https://astral.sh/uv/install.sh | sh |
|
} |
|
|
|
uv_bin() { |
|
if [ -x "$HOME/.local/bin/uv" ]; then |
|
echo "$HOME/.local/bin/uv" |
|
else |
|
command -v uv |
|
fi |
|
} |
|
|
|
install_harlequin() { |
|
local uv; uv="$(uv_bin)" || { echo "no uv"; return 1; } |
|
"$uv" tool install harlequin |
|
} |
|
|
|
install_visidata() { |
|
local uv; uv="$(uv_bin)" || { echo "no uv"; return 1; } |
|
"$uv" tool install visidata |
|
} |
|
|
|
# ---- go ---- |
|
install_go() { |
|
if command -v go >/dev/null 2>&1 && go version 2>/dev/null | grep -q 'go1\.'; then |
|
echo "go already installed"; return 0 |
|
fi |
|
local ver="1.23.4" |
|
local tmp; tmp=$(mktemp -d) |
|
curl -fsSL "https://go.dev/dl/go${ver}.linux-${GO_ARCH}.tar.gz" -o "$tmp/go.tgz" |
|
$SUDO rm -rf /usr/local/go |
|
$SUDO tar -C /usr/local -xzf "$tmp/go.tgz" |
|
rm -rf "$tmp" |
|
echo "go installed to /usr/local/go (add /usr/local/go/bin to PATH)" |
|
} |
|
|
|
# ---- zoxide ---- |
|
install_zoxide() { |
|
command -v zoxide >/dev/null 2>&1 && { echo "zoxide already installed"; return 0; } |
|
curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh |
|
} |
|
|
|
# ---- lazygit ---- |
|
install_lazygit() { |
|
command -v lazygit >/dev/null 2>&1 && { echo "lazygit already installed"; return 0; } |
|
local ver arch tmp |
|
ver=$(gh_latest_tag jesseduffield/lazygit) |
|
case "$UNAME_ARCH" in x86_64) arch=x86_64 ;; aarch64) arch=arm64 ;; esac |
|
tmp=$(mktemp -d) |
|
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${ver}/lazygit_${ver}_Linux_${arch}.tar.gz" \ |
|
-o "$tmp/lg.tgz" |
|
tar -xzf "$tmp/lg.tgz" -C "$tmp" |
|
install -m 0755 "$tmp/lazygit" "$BIN_DIR/lazygit" |
|
rm -rf "$tmp" |
|
} |
|
|
|
# ---- lazydocker ---- |
|
install_lazydocker() { |
|
command -v lazydocker >/dev/null 2>&1 && { echo "lazydocker already installed"; return 0; } |
|
local ver arch tmp |
|
ver=$(gh_latest_tag jesseduffield/lazydocker) |
|
case "$UNAME_ARCH" in x86_64) arch=x86_64 ;; aarch64) arch=arm64 ;; esac |
|
tmp=$(mktemp -d) |
|
curl -fsSL "https://github.com/jesseduffield/lazydocker/releases/download/v${ver}/lazydocker_${ver}_Linux_${arch}.tar.gz" \ |
|
-o "$tmp/ld.tgz" |
|
tar -xzf "$tmp/ld.tgz" -C "$tmp" |
|
install -m 0755 "$tmp/lazydocker" "$BIN_DIR/lazydocker" |
|
rm -rf "$tmp" |
|
} |
|
|
|
# ---- bottom (btm) ---- |
|
install_bottom() { |
|
command -v btm >/dev/null 2>&1 && { echo "bottom already installed"; return 0; } |
|
local ver tmp deb |
|
ver=$(gh_latest_tag ClementTsang/bottom) |
|
tmp=$(mktemp -d) |
|
deb="$tmp/bottom.deb" |
|
curl -fsSL "https://github.com/ClementTsang/bottom/releases/download/${ver}/bottom_${ver}-1_${DEB_ARCH}.deb" -o "$deb" |
|
$SUDO dpkg -i "$deb" |
|
rm -rf "$tmp" |
|
} |
|
|
|
# ---- glow ---- |
|
install_glow() { |
|
command -v glow >/dev/null 2>&1 && { echo "glow already installed"; return 0; } |
|
local ver arch tmp |
|
ver=$(gh_latest_tag charmbracelet/glow) |
|
case "$UNAME_ARCH" in x86_64) arch=x86_64 ;; aarch64) arch=arm64 ;; esac |
|
tmp=$(mktemp -d) |
|
curl -fsSL "https://github.com/charmbracelet/glow/releases/download/v${ver}/glow_${ver}_Linux_${arch}.tar.gz" \ |
|
-o "$tmp/glow.tgz" |
|
tar -xzf "$tmp/glow.tgz" -C "$tmp" |
|
local glow_bin; glow_bin=$(find "$tmp" -maxdepth 3 -type f -name glow | head -n1) |
|
install -m 0755 "$glow_bin" "$BIN_DIR/glow" |
|
rm -rf "$tmp" |
|
} |
|
|
|
# ---- yq ---- |
|
install_yq() { |
|
command -v yq >/dev/null 2>&1 && { echo "yq already installed"; return 0; } |
|
local arch |
|
case "$UNAME_ARCH" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac |
|
curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}" \ |
|
-o "$BIN_DIR/yq" |
|
chmod +x "$BIN_DIR/yq" |
|
} |
|
|
|
# ---- gron ---- |
|
install_gron() { |
|
command -v gron >/dev/null 2>&1 && { echo "gron already installed"; return 0; } |
|
local ver arch tmp |
|
ver=$(gh_latest_tag tomnomnom/gron) |
|
case "$UNAME_ARCH" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac |
|
tmp=$(mktemp -d) |
|
curl -fsSL "https://github.com/tomnomnom/gron/releases/download/v${ver}/gron-linux-${arch}-${ver}.tgz" \ |
|
-o "$tmp/gron.tgz" |
|
tar -xzf "$tmp/gron.tgz" -C "$tmp" |
|
install -m 0755 "$tmp/gron" "$BIN_DIR/gron" |
|
rm -rf "$tmp" |
|
} |
|
|
|
# ---- chained installs ---- |
|
|
|
# rust -> jnv (jnv waits for rust *only*; doesn't block other tools) |
|
launch_rust_then_jnv() { |
|
( |
|
echo "[rust] start" |
|
if install_rust >"$LOG_DIR/rust.log" 2>&1; then |
|
echo "[rust] ok" |
|
else |
|
echo "[rust] FAILED (see $LOG_DIR/rust.log)" |
|
exit 1 |
|
fi |
|
echo "[jnv] start" |
|
if install_jnv >"$LOG_DIR/jnv.log" 2>&1; then |
|
echo "[jnv] ok" |
|
else |
|
echo "[jnv] FAILED (see $LOG_DIR/jnv.log)" |
|
exit 1 |
|
fi |
|
) & |
|
PIDS+=("$!"); NAMES+=("rust+jnv") |
|
} |
|
|
|
# uv -> harlequin + visidata in parallel |
|
launch_uv_then_pytools() { |
|
( |
|
echo "[uv] start" |
|
if install_uv >"$LOG_DIR/uv.log" 2>&1; then |
|
echo "[uv] ok" |
|
else |
|
echo "[uv] FAILED (see $LOG_DIR/uv.log)" |
|
exit 1 |
|
fi |
|
# harlequin and visidata are independent — fan out. |
|
( |
|
echo "[harlequin] start" |
|
install_harlequin >"$LOG_DIR/harlequin.log" 2>&1 \ |
|
&& echo "[harlequin] ok" \ |
|
|| { echo "[harlequin] FAILED (see $LOG_DIR/harlequin.log)"; exit 1; } |
|
) & |
|
h_pid=$! |
|
( |
|
echo "[visidata] start" |
|
install_visidata >"$LOG_DIR/visidata.log" 2>&1 \ |
|
&& echo "[visidata] ok" \ |
|
|| { echo "[visidata] FAILED (see $LOG_DIR/visidata.log)"; exit 1; } |
|
) & |
|
v_pid=$! |
|
rc=0 |
|
wait "$h_pid" || rc=1 |
|
wait "$v_pid" || rc=1 |
|
exit $rc |
|
) & |
|
PIDS+=("$!"); NAMES+=("uv+harlequin+visidata") |
|
} |
|
|
|
launch_rust_then_jnv |
|
launch_uv_then_pytools |
|
launch go install_go |
|
launch zoxide install_zoxide |
|
launch lazygit install_lazygit |
|
launch lazydocker install_lazydocker |
|
launch bottom install_bottom |
|
launch glow install_glow |
|
launch yq install_yq |
|
launch gron install_gron |
|
|
|
echo |
|
echo "==> waiting for ${#PIDS[@]} background jobs..." |
|
echo " (tail any log live with: tail -f $LOG_DIR/<name>.log )" |
|
echo |
|
|
|
if wait_all; then |
|
echo |
|
echo "==> all installs done." |
|
else |
|
echo |
|
echo "==> finished with errors. Check failed logs in: $LOG_DIR" >&2 |
|
fi |
|
|
|
# =================================================================== |
|
# Phase 4: shell rc setup (idempotent) + verification report |
|
# =================================================================== |
|
echo |
|
echo "==> phase 4: shell rc setup + verification" |
|
|
|
# Detect login shell from $SHELL (we're running under bash, so $SHELL |
|
# reflects the user's actual login shell, not this script's interpreter). |
|
LOGIN_SHELL_NAME="$(basename "${SHELL:-/bin/bash}")" |
|
case "$LOGIN_SHELL_NAME" in |
|
zsh) RC="$HOME/.zshrc" ;; |
|
bash) RC="$HOME/.bashrc" ;; |
|
*) RC="$HOME/.bashrc" ;; |
|
esac |
|
touch "$RC" |
|
echo " rc file: $RC (login shell: $LOGIN_SHELL_NAME)" |
|
echo |
|
|
|
# ---- PATH ---- |
|
if grep -qF '.local/bin:$HOME/.cargo/bin:/usr/local/go/bin' "$RC"; then |
|
echo "[PATH] already set" |
|
else |
|
{ |
|
echo '' |
|
echo '# Added by install.sh — dev tools PATH' |
|
echo 'export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/usr/local/go/bin:$PATH"' |
|
} >> "$RC" |
|
echo "[PATH] appended to $RC" |
|
fi |
|
|
|
# ---- zoxide init ---- |
|
if grep -qE '^[^#]*zoxide init' "$RC"; then |
|
echo "[zoxide] init already set" |
|
elif ! command -v zoxide >/dev/null 2>&1 && ! [ -x "$HOME/.local/bin/zoxide" ]; then |
|
echo "[zoxide] binary not found — skipping rc entry" |
|
else |
|
{ |
|
echo '' |
|
echo '# Added by install.sh — zoxide' |
|
echo "eval \"\$(zoxide init $LOGIN_SHELL_NAME)\"" |
|
} >> "$RC" |
|
echo "[zoxide] init appended for $LOGIN_SHELL_NAME" |
|
fi |
|
|
|
# ---- fzf keybindings + completion ---- |
|
if [ "$LOGIN_SHELL_NAME" = "zsh" ]; then |
|
FZF_KB=/usr/share/doc/fzf/examples/key-bindings.zsh |
|
FZF_CO=/usr/share/doc/fzf/examples/completion.zsh |
|
else |
|
FZF_KB=/usr/share/doc/fzf/examples/key-bindings.bash |
|
FZF_CO=/usr/share/bash-completion/completions/fzf |
|
fi |
|
|
|
if [ -r "$FZF_KB" ]; then |
|
if grep -qF "$FZF_KB" "$RC"; then |
|
echo "[fzf] keybindings already sourced" |
|
else |
|
echo "[ -f $FZF_KB ] && source $FZF_KB" >> "$RC" |
|
echo "[fzf] keybindings appended" |
|
fi |
|
else |
|
echo "[fzf] keybindings file not found at $FZF_KB — skipping" |
|
fi |
|
|
|
if [ -r "$FZF_CO" ]; then |
|
if grep -qF "$FZF_CO" "$RC"; then |
|
echo "[fzf] completion already sourced" |
|
else |
|
echo "[ -f $FZF_CO ] && source $FZF_CO" >> "$RC" |
|
echo "[fzf] completion appended" |
|
fi |
|
else |
|
echo "[fzf] completion file not found at $FZF_CO — skipping" |
|
fi |
|
|
|
# ---- clipcat function (OSC 52 clipboard copy) ---- |
|
if grep -qE '^[[:space:]]*clipcat\(\)' "$RC"; then |
|
echo "[clipcat] already defined in $RC" |
|
else |
|
{ |
|
echo '' |
|
echo '# Added by install.sh — clipcat: copy file contents to terminal clipboard via OSC 52' |
|
echo 'clipcat() { base64 -w0 "$1" | sed '\''s/.*/\x1b]52;c;&\x07/'\'' > /dev/tty; }' |
|
} >> "$RC" |
|
echo "[clipcat] appended to $RC" |
|
fi |
|
|
|
# ---- docker group ---- |
|
if ! command -v docker >/dev/null 2>&1; then |
|
echo "[docker] not installed — skipping group check" |
|
elif id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then |
|
echo "[docker] $USER already in 'docker' group" |
|
else |
|
echo "[docker] adding $USER to 'docker' group..." |
|
if command -v sudo >/dev/null 2>&1; then |
|
sudo usermod -aG docker "$USER" \ |
|
&& echo "[docker] added — log out/in (or run: newgrp docker) to apply" |
|
else |
|
echo "[docker] sudo not found; run as root: usermod -aG docker $USER" |
|
fi |
|
fi |
|
|
|
# ---- verification report ---- |
|
echo |
|
echo "==> verification" |
|
echo |
|
|
|
# shellcheck disable=SC1090 |
|
source "$RC" 2>/dev/null || true |
|
|
|
ok() { printf " \033[32m✓\033[0m %s\n" "$1"; } |
|
miss() { printf " \033[31m✗\033[0m %s\n" "$1"; } |
|
|
|
echo "PATH segments:" |
|
for seg in "$HOME/.local/bin" "$HOME/.cargo/bin" /usr/local/go/bin; do |
|
if echo ":$PATH:" | grep -qF ":$seg:"; then |
|
ok "$seg" |
|
else |
|
miss "$seg (not in PATH)" |
|
fi |
|
done |
|
|
|
echo |
|
echo "Tools on PATH:" |
|
for t in git python3 docker gh rg fzf jq uv cargo go zoxide lazygit lazydocker btm glow yq gron jnv harlequin vd; do |
|
if command -v "$t" >/dev/null 2>&1; then |
|
ok "$t ($(command -v "$t"))" |
|
else |
|
miss "$t (not found)" |
|
fi |
|
done |
|
|
|
echo |
|
echo "Docker group:" |
|
if id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then |
|
ok "$USER is in 'docker' group (in /etc/group)" |
|
if groups | tr ' ' '\n' | grep -qx docker; then |
|
ok "and the current shell sees the group" |
|
else |
|
miss "but current shell hasn't picked it up — log out/in or 'newgrp docker'" |
|
fi |
|
else |
|
miss "$USER not in 'docker' group" |
|
fi |
|
|
|
echo |
|
echo "==> done. Open a new shell (or 'source $RC') so PATH/zoxide/fzf take effect." |