Compare commits
17
Commits
63609830af
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acdb761d6
|
||
|
|
18af4de0c0
|
||
|
|
0281b06154
|
||
|
|
9343715696
|
||
|
|
10caebad29
|
||
|
|
c4a804e760
|
||
|
|
eed5879a25
|
||
|
|
e01bb35043
|
||
|
|
52bca5ce38
|
||
|
|
b805357f53
|
||
|
|
d2ca6c6787
|
||
|
|
376fac0665
|
||
|
|
e54d77893a
|
||
|
|
8617450eb9
|
||
|
|
0afc798ee6
|
||
|
|
84e7dfb2b7
|
||
|
|
f2b17647f4
|
@@ -11,6 +11,7 @@ A collection of utility scripts for Linux desktop and server administration.
|
||||
| [`docker-stacks-backup/`](docker-stacks-backup/) | Backup & restore Docker Compose stacks — named volumes, bind mounts, pre/post hooks, retention, integrity verification |
|
||||
| [`docker-stacks-update/`](docker-stacks-update/) | Discover running Compose stacks, pull latest images, and restart only those that changed |
|
||||
| [`input-remapper-switcher/`](input-remapper-switcher/) | Auto-switch [input-remapper](https://github.com/sezanzeb/input-remapper) presets per-application via Hyprland socket events |
|
||||
| [`netbird-install-update/`](netbird-install-update/) | Install, update, and manage Netbird across single or multiple Linux hosts — detects install method, replaces package-manager installs with binary, supports SSH remote deployment, auto-update timers, and sudo password automation |
|
||||
| [`proxmox-backup/`](proxmox-backup/) | Backup & restore Proxmox VE configuration (local or over SSH) with integrity verification |
|
||||
| [`proxmox-cloudimg/`](proxmox-cloudimg/) | Download, customize, and create Proxmox VE VM templates from official cloud images |
|
||||
| [`zsh/`](zsh/) | Zsh configuration (`.zshrc`), dependency bootstrap installer, and themed tmux config |
|
||||
|
||||
@@ -60,7 +60,9 @@ The script finds stacks in three ways:
|
||||
|------|---------------|
|
||||
| `--all` | Every subdirectory of the **script's directory** containing a compose file |
|
||||
| `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` |
|
||||
| `--stack <spec>` | A specific stack by name (relative to the script's directory) or by absolute path |
|
||||
| `--stack <spec>` | A specific stack by name (relative to the **current directory**) or by absolute path |
|
||||
|
||||
Add `--dir <path>` to any command to override the default scan directory (script dir for backup/restore, current dir for init).
|
||||
|
||||
### External Stacks
|
||||
|
||||
@@ -76,6 +78,23 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
|
||||
|
||||
## Usage
|
||||
|
||||
### Init
|
||||
|
||||
```bash
|
||||
# Inject default x-backup config into all stacks in the current directory
|
||||
./docker-stack-backup.sh init
|
||||
|
||||
# Or specify a directory to scan (no cd needed)
|
||||
./docker-stack-backup.sh init --dir /mnt/data/stacks
|
||||
|
||||
# Or target specific stacks
|
||||
./docker-stack-backup.sh init --running
|
||||
./docker-stack-backup.sh init --stack random_stack
|
||||
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
|
||||
./docker-stack-backup.sh init -s stack_a -s stack_b
|
||||
./docker-stack-backup.sh init --dry-run --verbose # preview changes
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
@@ -94,6 +113,9 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
|
||||
./docker-stack-backup.sh backup -s stack_a -s stack_b
|
||||
./docker-stack-backup.sh backup --stack stack_a,stack_b
|
||||
|
||||
# Scan a specific directory for stacks instead of the script's directory
|
||||
./docker-stack-backup.sh backup --dir /srv/docker --all
|
||||
|
||||
# With a custom backup destination
|
||||
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
|
||||
|
||||
@@ -128,6 +150,9 @@ After selecting stacks, the script re-executes via `sudo` with your selections a
|
||||
# Restore an external stack by path
|
||||
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
|
||||
|
||||
# Restore to a different location (overrides the original path)
|
||||
./docker-stack-backup.sh restore --stack random_stack --to /new/location/random_stack
|
||||
|
||||
# Restore from a specific backup directory
|
||||
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
|
||||
```
|
||||
@@ -149,6 +174,38 @@ After selecting backups, the script re-executes via `sudo` with the resolved sta
|
||||
|
||||
When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically.
|
||||
|
||||
### Init (Inject Default Config)
|
||||
|
||||
```bash
|
||||
# Inject default x-backup config into all stacks in the current directory
|
||||
./docker-stack-backup.sh init
|
||||
|
||||
# Or target running/external stacks
|
||||
./docker-stack-backup.sh init --running
|
||||
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
|
||||
```
|
||||
|
||||
Scans each compose file (relative to the **current directory** by default, or use `--dir` to point elsewhere) and injects a default `x-backup` block at the top:
|
||||
|
||||
```yaml
|
||||
x-backup:
|
||||
stop: true
|
||||
backup-volumes: true
|
||||
# pre-hook: ""
|
||||
# post-hook: ""
|
||||
# pre-restore-hook: ""
|
||||
# post-restore-hook: ""
|
||||
# exclude:
|
||||
# - path/to/exclude
|
||||
# retention: 7
|
||||
```
|
||||
|
||||
Active defaults (`stop: true`, `backup-volumes: true`) are uncommented. Optional fields are commented out as a reference — uncomment and adjust as needed.
|
||||
|
||||
If the compose file already has an `x-backup:` block, it is removed first so the result is always a clean standard config. This makes `init` idempotent and safe to re-run at any time.
|
||||
|
||||
Does not require root by default — reads and writes compose files as the current user. If a compose file is owned by another user (e.g. root), the script automatically escalates with `sudo` for the write.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
@@ -383,7 +440,7 @@ Or as a cron job on the host (in root's crontab, or use `sudo`):
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation.
|
||||
- **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. The `init` command also runs without root by default, but auto-escalates with `sudo` if the compose file isn't writable by the current user.
|
||||
|
||||
When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root.
|
||||
|
||||
|
||||
@@ -13,13 +13,29 @@ TARGET_STACKS=()
|
||||
MODE="all" # all|running
|
||||
HAS_FLAGS=false
|
||||
ORIG_ARGS=()
|
||||
TARGET_DIR=""
|
||||
RESTORE_TARGET=""
|
||||
|
||||
# --- Helper Functions --------------------------------------------------------
|
||||
# --- Output Formatting --------------------------------------------------------
|
||||
|
||||
log() { if $VERBOSE; then echo "[INFO] $*"; fi; }
|
||||
warn() { echo "[WARN] $*" >&2; }
|
||||
err() { echo "[ERROR] $*" >&2; exit 1; }
|
||||
dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; }
|
||||
# Terminal colors (auto-disabled when not a TTY or tput unavailable)
|
||||
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
|
||||
BOLD=$(tput bold 2>/dev/null || true)
|
||||
RED=$(tput setaf 1 2>/dev/null || true)
|
||||
GREEN=$(tput setaf 2 2>/dev/null || true)
|
||||
YELLOW=$(tput setaf 3 2>/dev/null || true)
|
||||
BLUE=$(tput setaf 4 2>/dev/null || true)
|
||||
MAGENTA=$(tput setaf 5 2>/dev/null || true)
|
||||
CYAN=$(tput setaf 6 2>/dev/null || true)
|
||||
RESET=$(tput sgr0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
log() { $VERBOSE && echo " ${BLUE}ℹ${RESET} $*" || true; }
|
||||
warn() { echo " ${YELLOW}⚠${RESET} $*" >&2; }
|
||||
err() { echo " ${RED}✗${RESET} $*" >&2; exit 1; }
|
||||
dry() { if $DRY_RUN; then echo " ${MAGENTA}◇${RESET} $*"; else "$@"; fi; }
|
||||
success() { echo " ${GREEN}✓${RESET} $*"; }
|
||||
header() { echo ""; echo " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
|
||||
|
||||
# When run via sudo, chown files back to the original user
|
||||
fix_owner() {
|
||||
@@ -55,21 +71,25 @@ Commands:
|
||||
With no flags, shows an interactive menu.
|
||||
restore Restore one or more stacks from a backup.
|
||||
With no --stack flag, shows an interactive menu.
|
||||
init Inject default x-backup config into compose files
|
||||
that don't already have one.
|
||||
list List available stacks and backups
|
||||
verify Verify backup integrity (checks sha256 hash)
|
||||
|
||||
Options:
|
||||
-s, --stack <name> Target a specific stack (repeatable, or comma-separated).
|
||||
Can be a name (relative to stacks dir) or absolute path.
|
||||
Can be a name (relative to current directory) or absolute path.
|
||||
-a, --all Target all stacks found in stacks directory
|
||||
-r, --running Target only currently running compose projects wherever
|
||||
they live (discovers compose file paths automatically)
|
||||
-d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR})
|
||||
--dir <dir> Scan this directory for stacks (overrides default discovery dir)
|
||||
--to <path> Override restore target path (use with restore --stack)
|
||||
-n, --dry-run Show what would be done without making changes
|
||||
-v, --verbose Verbose output
|
||||
-h, --help Show this help message
|
||||
|
||||
Must be run as root (preserves file ownership in backups and restores).
|
||||
Must be run as root for backup/restore (preserves file ownership). The init and list commands do not require root.
|
||||
|
||||
Compose x-backup extensions (add to compose.yaml):
|
||||
x-backup:
|
||||
@@ -89,6 +109,9 @@ Examples:
|
||||
$(basename "$0") backup --stack random_stack --stack another_stack
|
||||
$(basename "$0") backup -s random_stack -v
|
||||
$(basename "$0") restore --stack random_stack -d /path/to/backups
|
||||
$(basename "$0") restore --stack random_stack --to /new/location/random_stack
|
||||
$(basename "$0") init
|
||||
$(basename "$0") init --running
|
||||
$(basename "$0") list
|
||||
EOF
|
||||
exit 0
|
||||
@@ -106,7 +129,7 @@ require_root() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
echo "Elevating to root via sudo..."
|
||||
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||
exec sudo "$0" "${ORIG_ARGS[@]}"
|
||||
}
|
||||
|
||||
@@ -183,7 +206,7 @@ resolve_target_stacks() {
|
||||
|
||||
if [ ${#explicit[@]} -gt 0 ]; then
|
||||
for spec in "${explicit[@]}"; do
|
||||
stack_spec_to_path "$stacks_dir" "$spec"
|
||||
stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec"
|
||||
done
|
||||
return
|
||||
fi
|
||||
@@ -328,9 +351,9 @@ apply_retention() {
|
||||
if [ "$count" -gt "$retention" ]; then
|
||||
local to_delete
|
||||
to_delete="$((count - retention))"
|
||||
log "Retention=$retention, pruning $to_delete old backup(s) for '$stack_name'"
|
||||
log "Retention=${retention}, pruning ${to_delete} old backup(s) for '${stack_name}'"
|
||||
find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do
|
||||
log " Pruning: $(basename "$f")"
|
||||
echo " ${MAGENTA}◇${RESET} Pruning: $(basename "$f")"
|
||||
dry rm -f "$f" "${f}.meta"
|
||||
done
|
||||
fi
|
||||
@@ -394,12 +417,12 @@ warn_named_volumes() {
|
||||
vols="$(detect_named_volumes "$compose_file")"
|
||||
[ -z "$vols" ] && return
|
||||
|
||||
echo " [!] Named volumes detected (not backed up by default):"
|
||||
echo " ${YELLOW}┃${RESET} Named volumes detected but ${BOLD}backup-volumes is false${RESET}, skipping:"
|
||||
for v in $vols; do
|
||||
echo " - $v"
|
||||
echo " ${YELLOW}┃${RESET} ${CYAN}●${RESET} $v"
|
||||
done
|
||||
echo " These store data inside Docker's storage area, not on the host filesystem."
|
||||
echo " Set 'x-backup.backup-volumes: false' to skip them."
|
||||
echo " ${YELLOW}┃${RESET} These store data inside Docker's storage area, not on the host filesystem."
|
||||
echo " ${YELLOW}┃${RESET} Set ${BOLD}x-backup.backup-volumes: true${RESET} to include them."
|
||||
}
|
||||
|
||||
# Resolve the actual Docker volume name for a compose volume reference.
|
||||
@@ -407,7 +430,11 @@ warn_named_volumes() {
|
||||
resolve_docker_volume() {
|
||||
local stack_name="$1"
|
||||
local vol_name="$2"
|
||||
echo "${stack_name}_${vol_name}"
|
||||
if [[ "$vol_name" == "${stack_name}_"* ]]; then
|
||||
echo "$vol_name"
|
||||
else
|
||||
echo "${stack_name}_${vol_name}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Back up named Docker volumes into a temporary directory.
|
||||
@@ -438,7 +465,7 @@ backup_named_volumes() {
|
||||
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
|
||||
log "Dumping named volume '$docker_vol'..."
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY-RUN] docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
|
||||
echo " ${MAGENTA}◇${RESET} docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
|
||||
else
|
||||
docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \
|
||||
warn "Failed to dump named volume '$docker_vol'"
|
||||
@@ -467,8 +494,8 @@ restore_named_volumes() {
|
||||
|
||||
log "Restoring named volume '$docker_vol'..."
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY-RUN] docker volume create $docker_vol"
|
||||
echo "[DRY-RUN] cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
|
||||
echo " ${MAGENTA}◇${RESET} docker volume create $docker_vol"
|
||||
echo " ${MAGENTA}◇${RESET} cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
|
||||
else
|
||||
docker volume rm "$docker_vol" 2>/dev/null || true
|
||||
docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; }
|
||||
@@ -483,7 +510,7 @@ restore_named_volumes() {
|
||||
rm -rf "$volumes_dir"
|
||||
|
||||
if $any_restored; then
|
||||
echo " -> Named volumes restored"
|
||||
success "Named volumes restored"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -521,7 +548,7 @@ stop_stack() {
|
||||
compose_file="$(resolve_compose_file "$stack_path")"
|
||||
|
||||
if stack_is_running "$stack_path"; then
|
||||
log "Stopping stack '$stack_name' ($stack_path)..."
|
||||
echo " ${CYAN}●${RESET} Stopping containers..."
|
||||
if [ -n "$compose_file" ]; then
|
||||
dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \
|
||||
dry docker compose -p "$stack_name" down 2>/dev/null || \
|
||||
@@ -544,7 +571,7 @@ start_stack() {
|
||||
compose_file="$(resolve_compose_file "$stack_path")"
|
||||
|
||||
if [ -n "$compose_file" ]; then
|
||||
log "Starting stack '$stack_name' ($stack_path)..."
|
||||
echo " ${CYAN}●${RESET} Starting containers..."
|
||||
dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \
|
||||
warn "Failed to start stack '$stack_name'"
|
||||
fi
|
||||
@@ -552,6 +579,12 @@ start_stack() {
|
||||
|
||||
# --- Backup ------------------------------------------------------------------
|
||||
|
||||
# Resolve the real path if the directory involves symlinks
|
||||
_resolve_dir() {
|
||||
local dir="$1"
|
||||
(cd -P "$dir" 2>/dev/null && pwd) || echo "$dir"
|
||||
}
|
||||
|
||||
backup_stack() {
|
||||
require_root
|
||||
local TIMESTAMP
|
||||
@@ -564,10 +597,16 @@ backup_stack() {
|
||||
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
|
||||
local archive_root
|
||||
archive_root="$(dirname "$stack_path")"
|
||||
archive_root="$(_resolve_dir "$archive_root")"
|
||||
local archive_basename
|
||||
archive_basename="$(basename "$stack_path")"
|
||||
local _backup_size=""
|
||||
|
||||
echo "Backing up stack '$stack_name' ($stack_path) -> $backup_file"
|
||||
header "Backup: ${stack_name}"
|
||||
|
||||
echo " ${CYAN}●${RESET} Source: ${BOLD}${stack_path}${RESET}"
|
||||
echo " ${CYAN}●${RESET} Archive: ${backup_file##*/}"
|
||||
echo ""
|
||||
|
||||
# Load x-backup config
|
||||
load_x_backup_config "$stack_path"
|
||||
@@ -581,13 +620,11 @@ backup_stack() {
|
||||
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
|
||||
local compose_file
|
||||
compose_file="$(resolve_compose_file "$stack_path")"
|
||||
local volumes_dumped=false
|
||||
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
|
||||
warn_named_volumes "$compose_file" "$stack_name"
|
||||
else
|
||||
log "Dumping named volumes..."
|
||||
echo " ${CYAN}●${RESET} Dumping named volumes..."
|
||||
backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
|
||||
volumes_dumped=true
|
||||
fi
|
||||
|
||||
# Pre-backup hook
|
||||
@@ -600,30 +637,46 @@ backup_stack() {
|
||||
dry mkdir -p "$backup_dir"
|
||||
fix_owner "$backup_dir" 2>/dev/null || true
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY-RUN] tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
|
||||
echo " ${MAGENTA}◇${RESET} tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
||||
if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
|
||||
local cr=$'\r'
|
||||
local clr=$'\033[K'
|
||||
echo -n " ${CYAN}●${RESET} Compressing..."
|
||||
tar -czf "$backup_file" \
|
||||
--checkpoint=500 \
|
||||
--checkpoint-action="ttyout=${cr} ${CYAN}●${RESET} %T${clr}" \
|
||||
--same-permissions --preserve-permissions \
|
||||
$XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
||||
echo -ne "\r\033[K\n" 2>/dev/null || true
|
||||
else
|
||||
echo " ${CYAN}●${RESET} Compressing..."
|
||||
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
||||
fi
|
||||
fix_owner "$backup_file"
|
||||
echo " -> Backup complete: $(du -h "$backup_file" | cut -f1)"
|
||||
_backup_size="$(du -h "$backup_file" | cut -f1)"
|
||||
fi
|
||||
|
||||
# Capture volume state before cleanup
|
||||
local has_volumes="false"
|
||||
[ -d "$stack_path/_volumes" ] && has_volumes="true"
|
||||
|
||||
# Clean up temporary volume dumps
|
||||
if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then
|
||||
if [ "$has_volumes" = "true" ]; then
|
||||
dry rm -rf "$stack_path/_volumes"
|
||||
fi
|
||||
|
||||
# Write metadata — used by the interactive restore menu and for
|
||||
# identifying backups without extracting the archive.
|
||||
if ! $DRY_RUN; then
|
||||
local has_volumes="false"
|
||||
[ -d "$stack_path/_volumes" ] && has_volumes="true"
|
||||
local archive_sha256 archive_size
|
||||
archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)"
|
||||
archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")"
|
||||
local resolved_path="${archive_root}/${archive_basename}"
|
||||
cat > "${backup_file}.meta" <<EOF
|
||||
stack=${stack_name}
|
||||
path=${stack_path}
|
||||
path=${resolved_path}
|
||||
date=${TIMESTAMP}
|
||||
compose=$(basename "${compose_file:-compose.yaml}")
|
||||
volumes=${has_volumes}
|
||||
@@ -645,6 +698,10 @@ EOF
|
||||
else
|
||||
start_stack "$stack_path"
|
||||
fi
|
||||
|
||||
if ! $DRY_RUN && [ -n "$_backup_size" ]; then
|
||||
success "Backup complete: ${BOLD}${_backup_size}${RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Interactive Restore Menu ------------------------------------------------
|
||||
@@ -704,7 +761,7 @@ interactive_restore_menu() {
|
||||
done
|
||||
|
||||
if [ ${#names[@]} -eq 0 ]; then
|
||||
echo "No backups found in $backup_dir"
|
||||
echo " ${YELLOW}┃${RESET} No backups found in ${backup_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -712,20 +769,19 @@ interactive_restore_menu() {
|
||||
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
|
||||
|
||||
echo ""
|
||||
echo "Available backups:"
|
||||
echo "------------------"
|
||||
echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}"
|
||||
local i
|
||||
for i in "${!names[@]}"; do
|
||||
local idx=$((i + 1))
|
||||
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
|
||||
printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
|
||||
done
|
||||
echo " all) Restore all stacks"
|
||||
echo " q) Cancel"
|
||||
echo " ${CYAN}all)${RESET} Restore all stacks"
|
||||
echo " ${YELLOW}q)${RESET} Cancel"
|
||||
echo ""
|
||||
|
||||
read -r -p "Select stacks to restore (numbers, 'all', or 'q'): " selection
|
||||
read -r -p " ${CYAN}›${RESET} Select stacks to restore (numbers, 'all', or 'q'): " selection
|
||||
|
||||
[ -z "$selection" ] && echo "Cancelled." && exit 0
|
||||
[ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0
|
||||
|
||||
# Normalise: handle "all"
|
||||
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
||||
@@ -736,7 +792,7 @@ interactive_restore_menu() {
|
||||
fi
|
||||
|
||||
if [ "$selection" = "q" ]; then
|
||||
echo "Cancelled."
|
||||
echo " ${YELLOW}┃${RESET} Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -756,13 +812,13 @@ interactive_restore_menu() {
|
||||
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
||||
selected_stacks+=("${names[$((seen_idx - 1))]}")
|
||||
else
|
||||
echo "Invalid selection: $seen_idx (out of range)"
|
||||
echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#selected_stacks[@]} -eq 0 ]; then
|
||||
echo "No valid stacks selected."
|
||||
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -786,13 +842,13 @@ interactive_restore_menu() {
|
||||
done
|
||||
|
||||
if [ ${#stack_flags[@]} -eq 0 ]; then
|
||||
echo "No valid stacks selected."
|
||||
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Elevate and re-exec with the selected stacks
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Elevating to root via sudo..."
|
||||
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||
exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}"
|
||||
fi
|
||||
|
||||
@@ -818,7 +874,7 @@ verify_backup() {
|
||||
fi
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
echo "No backups found."
|
||||
echo " ${YELLOW}┃${RESET} No backups found."
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -829,7 +885,7 @@ verify_backup() {
|
||||
local meta_file="${f}.meta"
|
||||
|
||||
if [ ! -f "$meta_file" ]; then
|
||||
echo " SKIP $basename_f (no .meta file)"
|
||||
echo " ${YELLOW}⚠${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)"
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -838,13 +894,13 @@ verify_backup() {
|
||||
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
|
||||
|
||||
if [ -z "$stored_hash" ]; then
|
||||
echo " SKIP $basename_f (no sha256 in .meta — legacy backup)"
|
||||
echo " ${YELLOW}⚠${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no sha256 in .meta — legacy backup)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check file still exists
|
||||
if [ ! -f "$f" ]; then
|
||||
echo " FAIL $basename_f (file missing)"
|
||||
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)"
|
||||
exit_code=1
|
||||
continue
|
||||
fi
|
||||
@@ -853,7 +909,7 @@ verify_backup() {
|
||||
local actual_size
|
||||
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
|
||||
if [ "$actual_size" != "$stored_size" ]; then
|
||||
echo " FAIL $basename_f (size mismatch: $actual_size vs $stored_size)"
|
||||
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (size mismatch: ${actual_size} vs ${stored_size})"
|
||||
exit_code=1
|
||||
continue
|
||||
fi
|
||||
@@ -862,12 +918,12 @@ verify_backup() {
|
||||
local actual_hash
|
||||
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
|
||||
if [ "$actual_hash" != "$stored_hash" ]; then
|
||||
echo " FAIL $basename_f (sha256 mismatch)"
|
||||
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (sha256 mismatch)"
|
||||
exit_code=1
|
||||
else
|
||||
local human_size
|
||||
human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")"
|
||||
echo " OK $basename_f ($human_size)"
|
||||
echo " ${GREEN}✓${RESET} ${GREEN}OK${RESET} ${basename_f} (${human_size})"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -929,25 +985,24 @@ interactive_backup_menu() {
|
||||
done
|
||||
|
||||
if [ ${#names[@]} -eq 0 ]; then
|
||||
echo "No stacks found."
|
||||
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Available stacks:"
|
||||
echo "-----------------"
|
||||
echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}"
|
||||
local i
|
||||
for i in "${!names[@]}"; do
|
||||
local idx=$((i + 1))
|
||||
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
|
||||
printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
|
||||
done
|
||||
echo " all) Back up all stacks"
|
||||
echo " q) Cancel"
|
||||
echo " ${CYAN}all)${RESET} Back up all stacks"
|
||||
echo " ${YELLOW}q)${RESET} Cancel"
|
||||
echo ""
|
||||
|
||||
read -r -p "Select stacks to back up (numbers, 'all', or 'q'): " selection
|
||||
read -r -p " ${CYAN}›${RESET} Select stacks to back up (numbers, 'all', or 'q'): " selection
|
||||
|
||||
[ -z "$selection" ] && echo "Cancelled." && exit 0
|
||||
[ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0
|
||||
|
||||
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
||||
selection=""
|
||||
@@ -957,7 +1012,7 @@ interactive_backup_menu() {
|
||||
fi
|
||||
|
||||
if [ "$selection" = "q" ]; then
|
||||
echo "Cancelled."
|
||||
echo " ${YELLOW}┃${RESET} Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -975,13 +1030,13 @@ interactive_backup_menu() {
|
||||
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
||||
selected_paths+=("${paths[$((seen_idx - 1))]}")
|
||||
else
|
||||
echo "Invalid selection: $seen_idx (out of range)"
|
||||
echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#selected_paths[@]} -eq 0 ]; then
|
||||
echo "No valid stacks selected."
|
||||
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -991,7 +1046,7 @@ interactive_backup_menu() {
|
||||
for spath in "${selected_paths[@]}"; do
|
||||
stack_flags+=("--stack" "$spath")
|
||||
done
|
||||
echo "Elevating to root via sudo..."
|
||||
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||
exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}"
|
||||
fi
|
||||
|
||||
@@ -1010,12 +1065,14 @@ restore_stack() {
|
||||
local stacks_dir="$1"
|
||||
local backup_dir="$2"
|
||||
local stack_path="$3"
|
||||
local restore_path="${4:-$stack_path}"
|
||||
local stack_name
|
||||
stack_name="$(stack_spec_to_name "$stack_path")"
|
||||
local archive_root
|
||||
archive_root="$(dirname "$stack_path")"
|
||||
archive_root="$(dirname "$restore_path")"
|
||||
archive_root="$(_resolve_dir "$archive_root")"
|
||||
local archive_basename
|
||||
archive_basename="$(basename "$stack_path")"
|
||||
archive_basename="$(basename "$restore_path")"
|
||||
|
||||
# Find the latest backup for this stack
|
||||
local backup_file
|
||||
@@ -1025,7 +1082,11 @@ restore_stack() {
|
||||
err "No backup found for stack '$stack_name' in $backup_dir"
|
||||
fi
|
||||
|
||||
echo "Restoring stack '$stack_name' ($stack_path) from $backup_file"
|
||||
header "Restore: ${stack_name}"
|
||||
|
||||
echo " ${CYAN}●${RESET} Target: ${BOLD}${restore_path}${RESET}"
|
||||
echo " ${CYAN}●${RESET} Backup: ${backup_file##*/}"
|
||||
echo ""
|
||||
|
||||
# Get list of files in backup to confirm it's the right one
|
||||
if $VERBOSE; then
|
||||
@@ -1070,20 +1131,32 @@ restore_stack() {
|
||||
|
||||
# Restore
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY-RUN] tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
|
||||
echo " ${MAGENTA}◇${RESET} tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
|
||||
else
|
||||
if [ -d "$stack_path" ]; then
|
||||
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
|
||||
log "Moving existing '$stack_name' to $existing_backup"
|
||||
mv "$stack_path" "$existing_backup"
|
||||
fi
|
||||
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
|
||||
if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
|
||||
local cr=$'\r'
|
||||
local clr=$'\033[K'
|
||||
echo -n " ${CYAN}●${RESET} Extracting..."
|
||||
tar -xzf "$backup_file" \
|
||||
--checkpoint=500 \
|
||||
--checkpoint-action="ttyout=${cr} ${CYAN}●${RESET} %T${clr}" \
|
||||
--same-permissions --preserve-permissions -C "$archive_root"
|
||||
echo -ne "\r\033[K\n" 2>/dev/null || true
|
||||
else
|
||||
echo " ${CYAN}●${RESET} Extracting..."
|
||||
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
|
||||
fi
|
||||
# Restore succeeded — remove the pre-restore safety copy since the
|
||||
# backup archive is the authoritative restore point.
|
||||
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
|
||||
dry rm -rf "$existing_backup"
|
||||
fi
|
||||
echo " -> Restore complete"
|
||||
success "Restore complete"
|
||||
# Restore named Docker volumes from the _volumes/ dir inside the archive
|
||||
restore_named_volumes "$stack_path" "$stack_name"
|
||||
fi
|
||||
@@ -1102,14 +1175,88 @@ restore_stack() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Init (inject default x-backup config) -----------------------------------
|
||||
|
||||
_remove_x_backup() {
|
||||
awk '
|
||||
/^x-backup:/ { skip=1; next }
|
||||
skip && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { skip=0 }
|
||||
skip { next }
|
||||
{ print }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
_init_inject_header() {
|
||||
local header="$1"
|
||||
local file="$2"
|
||||
local cp_cmd="${3:-cp}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
if grep -q '^---' "$file" 2>/dev/null; then
|
||||
awk -v h="$header" '!done && /^---/ { print; print ""; print h; done=1; next } 1' "$file" > "$tmp"
|
||||
else
|
||||
{ echo "$header"; echo ""; cat "$file"; } > "$tmp"
|
||||
fi
|
||||
$cp_cmd "$tmp" "$file" && rm -f "$tmp"
|
||||
}
|
||||
|
||||
init_stack() {
|
||||
local stack_path="$1"
|
||||
local compose_file
|
||||
compose_file="$(resolve_compose_file "$stack_path")"
|
||||
local stack_name
|
||||
stack_name="$(basename "$stack_path")"
|
||||
|
||||
[ -z "$compose_file" ] && { warn "No compose file in '$stack_name'"; return; }
|
||||
|
||||
local has_xb=false
|
||||
grep -q '^x-backup:' "$compose_file" 2>/dev/null && has_xb=true
|
||||
|
||||
local sudo_write=""
|
||||
[ -f "$compose_file" ] && [ ! -w "$compose_file" ] && sudo_write="sudo"
|
||||
local cp_cmd="${sudo_write:+sudo }cp"
|
||||
|
||||
local header
|
||||
header='x-backup:
|
||||
stop: true
|
||||
backup-volumes: true
|
||||
# pre-hook: ""
|
||||
# post-hook: ""
|
||||
# pre-restore-hook: ""
|
||||
# post-restore-hook: ""
|
||||
# exclude:
|
||||
# - path/to/exclude
|
||||
# retention: 7'
|
||||
|
||||
if $DRY_RUN; then
|
||||
local verb="inject"
|
||||
$has_xb && verb="replace"
|
||||
local maybe_sudo=""
|
||||
[ -n "$sudo_write" ] && maybe_sudo=" (with sudo)"
|
||||
echo " ${MAGENTA}◇${RESET} Init: would ${verb} x-backup config in ${BOLD}${compose_file}${RESET}${maybe_sudo}"
|
||||
return
|
||||
fi
|
||||
|
||||
if $has_xb; then
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
_remove_x_backup "$compose_file" > "$tmp"
|
||||
_init_inject_header "$header" "$tmp"
|
||||
$cp_cmd "$tmp" "$compose_file" && rm -f "$tmp"
|
||||
echo " ${GREEN}✓${RESET} ${BOLD}${stack_name}${RESET} x-backup config replaced"
|
||||
else
|
||||
_init_inject_header "$header" "$compose_file" "$cp_cmd"
|
||||
echo " ${GREEN}✓${RESET} ${BOLD}${stack_name}${RESET} x-backup config injected"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- List --------------------------------------------------------------------
|
||||
|
||||
list_stacks_and_backups() {
|
||||
local stacks_dir="$1"
|
||||
local backup_dir="$2"
|
||||
|
||||
echo "=== Stacks ==="
|
||||
# Collect all seen stack dirs to avoid duplicates
|
||||
echo " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}"
|
||||
local -A seen
|
||||
|
||||
# 1) Running stacks (discovered wherever they live)
|
||||
@@ -1132,9 +1279,9 @@ list_stacks_and_backups() {
|
||||
local config_tags
|
||||
config_tags="$(format_xb_tags)"
|
||||
if [ "$canon" != "$stacks_dir/$run_name" ]; then
|
||||
echo " - $run_name ($run_path) $label$config_tags"
|
||||
echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${BLUE}(${run_path})${RESET} ${label}${config_tags}"
|
||||
else
|
||||
echo " - $run_name $label$config_tags"
|
||||
echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}"
|
||||
fi
|
||||
has_running=true
|
||||
done <<< "$running"
|
||||
@@ -1156,18 +1303,18 @@ list_stacks_and_backups() {
|
||||
load_x_backup_config "$dir"
|
||||
local config_tags
|
||||
config_tags="$(format_xb_tags)"
|
||||
echo " - $stack$label$config_tags"
|
||||
echo " ${CYAN}●${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}"
|
||||
has_local=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $has_running && ! $has_local; then
|
||||
echo " No stacks found."
|
||||
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Backups ==="
|
||||
echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}"
|
||||
if [ -d "$backup_dir" ]; then
|
||||
local backup_count=0
|
||||
for f in "$backup_dir"/*.tar.gz; do
|
||||
@@ -1180,18 +1327,20 @@ list_stacks_and_backups() {
|
||||
mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')"
|
||||
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
|
||||
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
|
||||
[ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.."
|
||||
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)"
|
||||
[ "$mvols" = "true" ] && meta_info="$meta_info [volumes]"
|
||||
[ -n "$mhash" ] && meta_info="${meta_info} ${BLUE}sha256:${mhash:0:12}..${RESET}"
|
||||
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="${meta_info} ${CYAN}(${mpath})${RESET}"
|
||||
[ "$mvols" = "true" ] && meta_info="${meta_info} ${GREEN}[volumes]${RESET}"
|
||||
fi
|
||||
echo " $(basename "$f") ($(du -h "$f" | cut -f1)) -> $sname$meta_info"
|
||||
local fsize
|
||||
fsize="$(du -h "$f" | cut -f1)"
|
||||
echo " ${CYAN}●${RESET} ${BOLD}$(basename "$f")${RESET} ${BLUE}${fsize}${RESET} → ${sname}${meta_info}"
|
||||
backup_count=$((backup_count + 1))
|
||||
done
|
||||
if [ "$backup_count" -eq 0 ]; then
|
||||
echo " No backups found in $backup_dir"
|
||||
echo " ${YELLOW}┃${RESET} No backups found in ${backup_dir}"
|
||||
fi
|
||||
else
|
||||
echo " No backups directory found at $backup_dir"
|
||||
echo " ${YELLOW}┃${RESET} No backups directory found at ${backup_dir}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1211,7 +1360,7 @@ main() {
|
||||
# Parse arguments
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
backup|restore|list|verify)
|
||||
backup|restore|init|list|verify)
|
||||
ACTION="$1"
|
||||
shift
|
||||
;;
|
||||
@@ -1234,6 +1383,18 @@ main() {
|
||||
HAS_FLAGS=true
|
||||
shift
|
||||
;;
|
||||
--dir)
|
||||
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||
TARGET_DIR="$2"
|
||||
HAS_FLAGS=true
|
||||
shift 2
|
||||
;;
|
||||
--to)
|
||||
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||
RESTORE_TARGET="$2"
|
||||
HAS_FLAGS=true
|
||||
shift 2
|
||||
;;
|
||||
-d|--backup-dir)
|
||||
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||
backup_dir="$2"
|
||||
@@ -1260,11 +1421,32 @@ main() {
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_DIR" ]; then
|
||||
stacks_dir="$TARGET_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -d "$stacks_dir" ]; then
|
||||
err "Stacks directory not found: $stacks_dir"
|
||||
fi
|
||||
|
||||
case "$ACTION" in
|
||||
init)
|
||||
local scan_dir="${TARGET_DIR:-$INITIAL_DIR}"
|
||||
if ! $HAS_FLAGS; then
|
||||
MODE="all"
|
||||
fi
|
||||
local stacks
|
||||
stacks="$(resolve_target_stacks "$scan_dir" "$MODE" "${TARGET_STACKS[@]}")"
|
||||
if [ -z "$stacks" ]; then
|
||||
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||
exit 0
|
||||
fi
|
||||
for stack in $stacks; do
|
||||
local spath
|
||||
spath="$(stack_spec_to_path "$scan_dir" "$stack")"
|
||||
init_stack "$spath"
|
||||
done
|
||||
;;
|
||||
list)
|
||||
list_stacks_and_backups "$stacks_dir" "$backup_dir"
|
||||
;;
|
||||
@@ -1278,7 +1460,8 @@ main() {
|
||||
local stacks
|
||||
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
|
||||
if [ -z "$stacks" ]; then
|
||||
echo "No stacks found in '$stacks_dir'. Either cd there, pass --stack /path, or use -d to set the backup directory."
|
||||
echo " ${YELLOW}┃${RESET} No stacks found in ${BOLD}${stacks_dir}${RESET}."
|
||||
echo " ${CYAN}→${RESET} Either cd there, pass ${BOLD}--stack /path${RESET}, or use ${BOLD}-d${RESET} to set the backup directory."
|
||||
exit 0
|
||||
fi
|
||||
local stack
|
||||
@@ -1299,8 +1482,9 @@ main() {
|
||||
fi
|
||||
for spec in "${TARGET_STACKS[@]}"; do
|
||||
local spath
|
||||
spath="$(stack_spec_to_path "$stacks_dir" "$spec")"
|
||||
restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'"
|
||||
spath="$(stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec")"
|
||||
local restore_path="${RESTORE_TARGET:-$spath}"
|
||||
restore_stack "$stacks_dir" "$backup_dir" "$spath" "$restore_path" || warn "Restore failed for '$spath'"
|
||||
done
|
||||
;;
|
||||
verify)
|
||||
|
||||
@@ -9,15 +9,21 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Terminal colours (disabled when not on a terminal) ──────────────
|
||||
if [[ -t 1 ]]; then
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
else
|
||||
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC=''
|
||||
# ── Terminal colours ──────────────────────────────────────────────
|
||||
BOLD=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
|
||||
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
|
||||
BOLD=$(tput bold 2>/dev/null || true)
|
||||
RED=$(tput setaf 1 2>/dev/null || true)
|
||||
GREEN=$(tput setaf 2 2>/dev/null || true)
|
||||
YELLOW=$(tput setaf 3 2>/dev/null || true)
|
||||
CYAN=$(tput setaf 6 2>/dev/null || true)
|
||||
RESET=$(tput sgr0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ── State ───────────────────────────────────────────────────────────
|
||||
TARGET_STACKS=() # user-specified stacks (empty = all)
|
||||
LIST_ONLY=false # --list flag
|
||||
all_names=() # pre-filter stack names (for error messages)
|
||||
updated_names=() # stack names that were updated
|
||||
updated_detail=() # parallel: "stack: service1, service2"
|
||||
unchanged_names=() # stack names already up to date
|
||||
@@ -25,24 +31,58 @@ orphaned_names=() # stacks whose compose files are gone – removed
|
||||
failed_names=() # stack names that errored
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||
ok() { echo -e " ${GREEN}✓${NC} $*"; }
|
||||
warn() { echo -e " ${YELLOW}~${NC} $*"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $*"; }
|
||||
log() { echo -e " ${CYAN}ℹ${RESET} $*"; }
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e " ${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; }
|
||||
header() { echo ""; echo -e " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Pull latest images and restart all running Docker Compose stacks
|
||||
that have updates.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message
|
||||
-l, --list List running stacks and exit
|
||||
-s, --stack <name> Target a specific stack (repeatable)
|
||||
|
||||
No options = update all running stacks.
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Parse arguments ──────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
-l|--list) LIST_ONLY=true; shift ;;
|
||||
-s|--stack)
|
||||
shift
|
||||
[[ $# -eq 0 ]] && { echo -e " ${RED}✗${RESET} Option --stack requires a name." >&2; exit 1; }
|
||||
TARGET_STACKS+=("$1")
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo -e " ${RED}✗${RESET} Unknown option: $1" >&2; usage ;;
|
||||
*)
|
||||
echo -e " ${RED}✗${RESET} Unexpected argument: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Pre-flight ──────────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2
|
||||
echo -e "${RED}✗${RESET} Error: 'docker' not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BOLD}Docker Compose Stack Updater${NC}"
|
||||
echo -e "${BOLD}────────────────────────────${NC}"
|
||||
echo
|
||||
header "Docker Compose Stack Updater"
|
||||
|
||||
# ── Discover stacks ─────────────────────────────────────────────────
|
||||
info "Scanning for running Docker Compose stacks ..."
|
||||
echo
|
||||
log "Scanning for running Docker Compose stacks ..."
|
||||
|
||||
stack_json=$(docker compose ls --format json 2>/dev/null || true)
|
||||
|
||||
@@ -53,7 +93,7 @@ fi
|
||||
|
||||
# Parse JSON with jq (required).
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo -e "${RED}Error: 'jq' is required. Install it first.${NC}" >&2
|
||||
echo -e "${RED}✗${RESET} Error: 'jq' is required. Install it first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -61,16 +101,73 @@ readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
|
||||
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
|
||||
|
||||
total=${#names[@]}
|
||||
echo " Found ${total} running stack(s):"
|
||||
printf " • %s\n" "${names[@]}"
|
||||
echo
|
||||
echo " ${CYAN}●${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
|
||||
for name in "${names[@]}"; do
|
||||
echo " ${CYAN}·${RESET} ${name}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
if $LIST_ONLY; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Spinner for long operations ─────────────────────────────────────
|
||||
_spin_kill=""
|
||||
|
||||
_spin_start() {
|
||||
local msg="$1"
|
||||
local -a ch=('-' '\' '|' '/')
|
||||
local i=0
|
||||
(
|
||||
while true; do
|
||||
echo -ne "\r ${CYAN}${ch[i]}${RESET} ${msg} "
|
||||
i=$(( (i+1) % 4 ))
|
||||
sleep 0.15
|
||||
done
|
||||
) &
|
||||
_spin_kill=$!
|
||||
disown 2>/dev/null || true
|
||||
}
|
||||
|
||||
_spin_stop() {
|
||||
[[ -z "$_spin_kill" ]] && return
|
||||
kill "$_spin_kill" 2>/dev/null || true
|
||||
wait "$_spin_kill" 2>/dev/null || true
|
||||
_spin_kill=""
|
||||
echo -ne "\r\033[K"
|
||||
}
|
||||
|
||||
# ── Filter stacks if --stack was given ───────────────────────────────
|
||||
if [[ ${#TARGET_STACKS[@]} -gt 0 ]]; then
|
||||
all_names=("${names[@]}")
|
||||
filtered_names=()
|
||||
filtered_configs=()
|
||||
for i in "${!names[@]}"; do
|
||||
for t in "${TARGET_STACKS[@]}"; do
|
||||
if [[ "${names[$i]}" == "$t" ]]; then
|
||||
filtered_names+=("${names[$i]}")
|
||||
filtered_configs+=("${configs[$i]}")
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
names=("${filtered_names[@]}")
|
||||
configs=("${filtered_configs[@]}")
|
||||
if [[ ${#names[@]} -eq 0 ]]; then
|
||||
warn "No running stacks match the requested name(s)."
|
||||
echo " ${CYAN}→${RESET} Running stacks: ${all_names[*]}"
|
||||
exit 0
|
||||
fi
|
||||
echo " ${CYAN}→${RESET} Targeting ${BOLD}${#names[@]}${RESET} of ${BOLD}${total}${RESET} running stack(s)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Process each stack ──────────────────────────────────────────────
|
||||
for i in "${!names[@]}"; do
|
||||
stack="${names[$i]}"
|
||||
cfg_str="${configs[$i]}"
|
||||
|
||||
echo -e "${BOLD}── ${stack}${NC}"
|
||||
echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
|
||||
|
||||
# Support multi-file projects (ConfigFiles is comma-separated).
|
||||
IFS=',' read -ra cfg_files <<< "$cfg_str"
|
||||
@@ -95,14 +192,15 @@ for i in "${!names[@]}"; do
|
||||
if [[ ${#missing_files[@]} -gt 0 ]]; then
|
||||
warn "Orphaned stack — compose file(s) missing:"
|
||||
for m in "${missing_files[@]}"; do
|
||||
echo " ${m}"
|
||||
echo " ${m}"
|
||||
done
|
||||
info "Tearing down orphaned stack ..."
|
||||
_spin_start "Tearing down orphaned stack..."
|
||||
if docker compose -p "$stack" down 2>/dev/null; then
|
||||
ok "${stack} — orphaned stack removed"
|
||||
_spin_stop; ok "${stack} — orphaned stack removed"
|
||||
else
|
||||
_spin_stop
|
||||
# Fall back to direct container / network cleanup by project label.
|
||||
info "Fallback: removing containers by project label …"
|
||||
log "Fallback: removing containers by project label..."
|
||||
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
|
||||
if [[ -n "$containers" ]]; then
|
||||
docker container rm -f $containers 2>/dev/null || true
|
||||
@@ -114,49 +212,43 @@ for i in "${!names[@]}"; do
|
||||
ok "${stack} — orphaned containers removed"
|
||||
fi
|
||||
orphaned_names+=("$stack")
|
||||
echo; continue
|
||||
echo ""; continue
|
||||
fi
|
||||
|
||||
info "Pulling latest images ..."
|
||||
# Pull with spinner (capture output for parsing later).
|
||||
_spin_start "Pulling latest images..."
|
||||
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
|
||||
_spin_stop
|
||||
fail "Image pull failed for ${stack}"
|
||||
echo "$pull_out" | head -5
|
||||
echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
|
||||
failed_names+=("$stack")
|
||||
echo; continue
|
||||
echo ""; continue
|
||||
}
|
||||
_spin_stop
|
||||
|
||||
# Always run up -d after a successful pull (idempotent when nothing
|
||||
# changed).
|
||||
info "Recreating containers …"
|
||||
# Recreate containers with spinner.
|
||||
_spin_start "Recreating containers..."
|
||||
docker compose "${compose_args[@]}" up -d 2>&1 || {
|
||||
_spin_stop
|
||||
fail "Container recreation failed for ${stack}"
|
||||
failed_names+=("$stack"); echo; continue
|
||||
failed_names+=("$stack"); echo ""; continue
|
||||
}
|
||||
_spin_stop
|
||||
|
||||
# Determine which services (if any) received new images by parsing the
|
||||
# pull output. Only the pull output is authoritative — up -d restarts
|
||||
# are idempotent and tell us nothing about whether images changed.
|
||||
#
|
||||
# Patterns matched (TTY and non-TTY alike):
|
||||
# " ✔ svc Pulled" / "svc Pulled"
|
||||
# "Downloaded newer image for svc"
|
||||
# The only reliable indicator of an actual image update is the
|
||||
# "Downloaded newer image for <service>" string. The "Pulled" status
|
||||
# appears even when the image is already up to date (Docker shows
|
||||
# "Pulled" for the manifest check), so we ignore it.
|
||||
updated_svc=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
# "Downloaded newer image for svc" — unambiguous.
|
||||
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then
|
||||
updated_svc+=("${BASH_REMATCH[1]}"); continue
|
||||
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
|
||||
updated_svc+=("${BASH_REMATCH[1]}")
|
||||
fi
|
||||
|
||||
# "Pulled" not "Already up to date"
|
||||
[[ "$line" =~ Already\ up\ to\ date ]] && continue
|
||||
[[ ! "$line" =~ Pulled ]] && continue
|
||||
|
||||
# Extract the word before "Pulled". Strip leading " ✔ " if
|
||||
# present, then read the first field.
|
||||
cleaned="${line#*✔ }"
|
||||
read -r svc _ <<< "$cleaned"
|
||||
[[ -n "$svc" && "$svc" != "Pulled" ]] && updated_svc+=("$svc")
|
||||
done <<< "$pull_out"
|
||||
|
||||
# Deduplicate while preserving order.
|
||||
@@ -170,49 +262,52 @@ for i in "${!names[@]}"; do
|
||||
done
|
||||
|
||||
if [[ ${#deduped[@]} -gt 0 ]]; then
|
||||
ok "${stack} — updated: ${deduped[*]}"
|
||||
ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
|
||||
updated_names+=("$stack")
|
||||
updated_detail+=("${stack}: ${deduped[*]}")
|
||||
else
|
||||
info "${stack} — already up to date"
|
||||
log "${stack} — already up to date"
|
||||
unchanged_names+=("$stack")
|
||||
fi
|
||||
|
||||
echo
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────
|
||||
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
|
||||
echo -e "${BOLD} Summary${NC}"
|
||||
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
|
||||
echo
|
||||
header "Summary"
|
||||
|
||||
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
|
||||
echo " Total stacks found: ${total_found}"
|
||||
echo
|
||||
echo " ${CYAN}●${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
|
||||
echo ""
|
||||
|
||||
if [[ ${#updated_names[@]} -gt 0 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} ${BOLD}Updated (${#updated_names[@]})${NC}"
|
||||
echo -e " ${GREEN}✓${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
|
||||
for d in "${updated_detail[@]}"; do
|
||||
echo " ${d}"
|
||||
done
|
||||
echo
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#unchanged_names[@]} -gt 0 ]]; then
|
||||
echo -e " ${YELLOW}−${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}"
|
||||
printf " %s\n" "${unchanged_names[@]}"
|
||||
echo
|
||||
echo -e " ${CYAN}−${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
|
||||
for s in "${unchanged_names[@]}"; do
|
||||
echo " ${CYAN}·${RESET} ${s}"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#orphaned_names[@]} -gt 0 ]]; then
|
||||
echo -e " ${RED}✗${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}"
|
||||
printf " %s\n" "${orphaned_names[@]}"
|
||||
echo
|
||||
echo -e " ${YELLOW}⚠${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
|
||||
for s in "${orphaned_names[@]}"; do
|
||||
echo " ${YELLOW}·${RESET} ${s}"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#failed_names[@]} -gt 0 ]]; then
|
||||
echo -e " ${RED}✗${NC} ${BOLD}Failed (${#failed_names[@]})${NC}"
|
||||
printf " %s\n" "${failed_names[@]}"
|
||||
echo
|
||||
echo -e " ${RED}✗${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
|
||||
for s in "${failed_names[@]}"; do
|
||||
echo " ${RED}·${RESET} ${s}"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
# Netbird Install/Update Script
|
||||
|
||||
A comprehensive bash script for installing, updating, and managing Netbird across Linux systems. Supports local execution, single-host remote deployment, and multi-host fleet management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart Installation**: Automatically detects and handles different Netbird installation methods
|
||||
- **Package Manager Replacement**: Converts package manager installations to binary installs for better control
|
||||
- **Connection-Safe Updates**: Background update process survives SSH disconnections during Netbird service restarts
|
||||
- **Auto-Update Timer**: Systemd timer for daily automatic updates with persistent scheduling
|
||||
- **Remote Deployment**: Deploy to single hosts or entire fleets via SSH with timeout handling
|
||||
- **Detailed Summary**: Multi-host deployments show which hosts succeeded/failed and what action was performed
|
||||
- **SELinux Compatible**: Automatically fixes SELinux contexts on RHEL/Fedora systems
|
||||
- **No Artifacts**: Clean execution with no leftover files or logs
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux operating system
|
||||
- Root/sudo access
|
||||
- curl
|
||||
- SSH client (for remote deployment)
|
||||
- Internet connection to download Netbird
|
||||
|
||||
## Installation
|
||||
|
||||
Make the script executable:
|
||||
|
||||
```bash
|
||||
chmod +x netbird-install-update.sh
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Local Execution
|
||||
|
||||
Install or update Netbird on the local machine:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh
|
||||
```
|
||||
|
||||
Install with automatic daily updates:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --timer
|
||||
```
|
||||
|
||||
Install with custom update schedule:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --timer --time "03:00"
|
||||
```
|
||||
|
||||
### Single Host Remote Deployment
|
||||
|
||||
Deploy to a remote host via SSH:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --ssh user@hostname
|
||||
```
|
||||
|
||||
Deploy with sudo password (non-interactive):
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --ssh user@hostname --password "your_sudo_password"
|
||||
```
|
||||
|
||||
Deploy with auto-update timer enabled:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --ssh user@hostname --timer
|
||||
```
|
||||
|
||||
Deploy with custom update schedule:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --ssh user@hostname --timer --time "03:00"
|
||||
```
|
||||
|
||||
### Multi-Host Fleet Deployment
|
||||
|
||||
Deploy to multiple hosts using a hosts file:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --file hosts.txt
|
||||
```
|
||||
|
||||
Deploy with default sudo password for all hosts:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --file hosts.txt --password "your_sudo_password"
|
||||
```
|
||||
|
||||
Deploy with auto-update timer to all hosts:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --file hosts.txt --timer
|
||||
```
|
||||
|
||||
Deploy with custom update schedule to all hosts:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh --file hosts.txt --timer --time "03:00"
|
||||
```
|
||||
|
||||
#### Hosts File Format
|
||||
|
||||
Create a text file with one hostname or IP address per line. Empty lines and comments (starting with `#`) are ignored.
|
||||
|
||||
**Optional: Include sudo password**
|
||||
|
||||
You can optionally include the sudo password after the hostname (separated by space). Host-specific passwords override the default password provided via `--password`.
|
||||
|
||||
```bash
|
||||
# Production servers (with host-specific sudo passwords)
|
||||
prod-web-01.example.com MySudoPassword123
|
||||
prod-web-02.example.com MySudoPassword456
|
||||
prod-db-01.example.com MySudoPassword789
|
||||
|
||||
# Development servers (will use default password from --password flag)
|
||||
dev-app-01.example.com
|
||||
dev-app-02.example.com
|
||||
|
||||
# IP addresses also work
|
||||
192.168.1.100
|
||||
10.0.0.50 MyPassword
|
||||
```
|
||||
|
||||
**Password Precedence**:
|
||||
1. Host-specific password in hosts file (highest priority)
|
||||
2. Default password from `--password` flag
|
||||
3. Interactive sudo prompt (if no password provided)
|
||||
|
||||
**Security Warning**: Storing passwords in plain text is not recommended for production environments. Consider using SSH key authentication with passwordless sudo instead.
|
||||
|
||||
**Requirements**:
|
||||
- If a password is provided (via hosts file or `--password` flag), `sshpass` must be installed on the local machine
|
||||
- If no password is provided, you'll be prompted to enter the sudo password interactively for each host
|
||||
|
||||
See `hosts.example` for a template.
|
||||
|
||||
## Command-Line Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--ssh <host>` | Deploy to a single remote host via SSH |
|
||||
| `--file <path>` | Deploy to multiple hosts listed in a file |
|
||||
| `--hosts-file <path>` | Alias for `--file` |
|
||||
| `--timer` | Install systemd timer for auto-updates |
|
||||
| `--time <schedule>` | Set update schedule (default: "daily") |
|
||||
| `--password, -p <password>` | Default sudo password for remote hosts |
|
||||
|
||||
The `--time` option accepts systemd calendar event format. Examples:
|
||||
- `daily` - Run once per day at midnight (default)
|
||||
- `03:00` - Run at 3:00 AM daily
|
||||
- `Mon,Fri 02:30` - Run at 2:30 AM on Monday and Friday
|
||||
- `hourly` - Run every hour
|
||||
- `weekly` - Run once per week
|
||||
|
||||
The `--password` option provides a default sudo password for remote deployments. This can be overridden per-host in the hosts file.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Installation Detection
|
||||
|
||||
The script detects three installation states:
|
||||
|
||||
1. **Not Installed**: Netbird binary not found in PATH
|
||||
2. **Package Manager Install**: Installed via apt, yum, dnf, zypper, pacman, or rpm-ostree
|
||||
3. **Binary Install**: Installed via the official curl-based installer
|
||||
|
||||
Detection is performed by:
|
||||
- Checking `/etc/netbird/install.conf` (authoritative source)
|
||||
- Querying package managers (dpkg, rpm)
|
||||
- Falling back to binary install detection
|
||||
|
||||
### Installation Flow
|
||||
|
||||
**Fresh Installation (Not Installed)**:
|
||||
1. Downloads official Netbird install script
|
||||
2. Runs binary-only installation (no GUI, no package manager)
|
||||
3. Ensures proper ownership and permissions
|
||||
4. Fixes SELinux contexts if applicable
|
||||
|
||||
**Package Manager Replacement**:
|
||||
1. Downloads official Netbird install script
|
||||
2. Schedules background replacement (3-second delay)
|
||||
3. Stops Netbird service
|
||||
4. Removes package manager installation
|
||||
5. Runs binary installation
|
||||
6. Ensures proper ownership, permissions, and SELinux contexts
|
||||
|
||||
**Binary Update**:
|
||||
1. Compares current version with latest release
|
||||
2. If outdated, schedules background update (3-second delay)
|
||||
3. Runs official update process
|
||||
4. Ensures proper ownership, permissions, and SELinux contexts
|
||||
|
||||
### Connection-Safe Updates
|
||||
|
||||
When updating over SSH, the script uses a detached background process to avoid disconnection issues:
|
||||
|
||||
```bash
|
||||
setsid bash -c 'sleep 3 && [update commands]' </dev/null >/var/log/netbird-update.log 2>&1 &
|
||||
```
|
||||
|
||||
The 3-second delay allows the SSH session to complete cleanly before the Netbird service is stopped and restarted. The update continues even if your connection drops.
|
||||
|
||||
### Auto-Update Timer
|
||||
|
||||
When `--timer` is specified, the script creates a systemd timer that:
|
||||
|
||||
- Runs on a configurable schedule (default: daily at midnight with random 0-10 minute delay)
|
||||
- Uses `Persistent=true` to catch up on missed runs after system boot
|
||||
- Logs output to `/var/log/netbird-update.log`
|
||||
- Automatically updates existing timers if already installed
|
||||
|
||||
The schedule can be customized with the `--time` option. Examples:
|
||||
- `--time "daily"` - Run once per day at midnight (default)
|
||||
- `--time "03:00"` - Run at 3:00 AM daily
|
||||
- `--time "Mon,Fri 02:30"` - Run at 2:30 AM on Monday and Friday
|
||||
- `--time "hourly"` - Run every hour
|
||||
- `--time "weekly"` - Run once per week
|
||||
|
||||
The timer consists of two systemd units:
|
||||
|
||||
**netbird-update.service**:
|
||||
```ini
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash -c 'curl -fsSL https://pkgs.netbird.io/install.sh | UPDATE_NETBIRD=true sh && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true'
|
||||
```
|
||||
|
||||
**netbird-update.timer**:
|
||||
```ini
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=600
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
All background operations log to `/var/log/netbird-update.log`:
|
||||
|
||||
- Package manager replacement operations
|
||||
- In-place updates
|
||||
- Systemd timer executions
|
||||
|
||||
Check the log to monitor update progress or troubleshoot issues:
|
||||
|
||||
```bash
|
||||
sudo tail -f /var/log/netbird-update.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Netbird Service Fails to Start
|
||||
|
||||
If the Netbird service fails with "Permission denied" errors:
|
||||
|
||||
```bash
|
||||
# Check binary permissions
|
||||
ls -la /usr/bin/netbird
|
||||
|
||||
# Fix ownership and permissions
|
||||
sudo chown root:root /usr/bin/netbird
|
||||
sudo chmod +x /usr/bin/netbird
|
||||
|
||||
# Fix SELinux context (RHEL/Fedora)
|
||||
sudo restorecon -v /usr/bin/netbird
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart netbird
|
||||
```
|
||||
|
||||
### Check Update Status
|
||||
|
||||
```bash
|
||||
# Check current version
|
||||
netbird version
|
||||
|
||||
# Check service status
|
||||
sudo systemctl status netbird
|
||||
|
||||
# View update logs
|
||||
sudo cat /var/log/netbird-update.log
|
||||
|
||||
# Check timer status (if installed)
|
||||
systemctl status netbird-update.timer
|
||||
```
|
||||
|
||||
### Manual Update
|
||||
|
||||
If you need to manually trigger an update:
|
||||
|
||||
```bash
|
||||
./netbird-install-update.sh
|
||||
```
|
||||
|
||||
Or use the official Netbird update method:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pkgs.netbird.io/install.sh | sudo UPDATE_NETBIRD=true sh
|
||||
```
|
||||
|
||||
### Timer Management
|
||||
|
||||
```bash
|
||||
# Check timer status
|
||||
systemctl status netbird-update.timer
|
||||
|
||||
# View timer schedule
|
||||
systemctl list-timers netbird-update.timer
|
||||
|
||||
# Manually trigger timer
|
||||
sudo systemctl start netbird-update.service
|
||||
|
||||
# Disable timer
|
||||
sudo systemctl disable --now netbird-update.timer
|
||||
|
||||
# Remove timer completely
|
||||
sudo systemctl disable --now netbird-update.timer
|
||||
sudo rm /etc/systemd/system/netbird-update.service
|
||||
sudo rm /etc/systemd/system/netbird-update.timer
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
## Why Binary Install?
|
||||
|
||||
The script converts package manager installations to binary installs because:
|
||||
|
||||
1. **Better Control**: Direct binary management allows precise version control
|
||||
2. **Simpler Updates**: Binary updates don't require package manager configuration
|
||||
3. **Consistency**: Same installation method across all Linux distributions
|
||||
4. **Official Support**: Binary install is the officially recommended method by Netbird
|
||||
|
||||
## Remote Deployment Details
|
||||
|
||||
When deploying remotely:
|
||||
|
||||
1. Script copies itself to the remote host via `scp`
|
||||
2. Executes remotely with `sudo` via `ssh -t` (terminal allocation for sudo password prompt)
|
||||
3. Passes through additional flags (like `--timer`)
|
||||
4. Cleans up the remote copy after execution
|
||||
|
||||
The remote deployment continues even if the local script is interrupted.
|
||||
|
||||
**Important Requirements**:
|
||||
- **SSH Authentication**: Remote deployment requires SSH key authentication. Password-based SSH authentication is not supported.
|
||||
- **Sudo Access**: The script will prompt for the sudo password on each remote host. For fully automated deployments, configure passwordless sudo on the remote hosts (e.g., add a sudoers rule like `username ALL=(ALL) NOPASSWD: /bin/bash`).
|
||||
|
||||
### SSH Timeout and Error Handling
|
||||
|
||||
The script includes a 30-second timeout for SSH connections to prevent hanging on unreachable hosts. If a host is unreachable or the connection times out, the script will:
|
||||
|
||||
- Mark the host as failed in the deployment summary
|
||||
- Continue to the next host in the list
|
||||
- Report the failure at the end
|
||||
|
||||
### Deployment Summary
|
||||
|
||||
After multi-host deployment, the script provides a detailed summary showing:
|
||||
|
||||
- **Total successful and failed deployments**
|
||||
- **List of successful hosts** with the action performed:
|
||||
- `fresh install` - Netbird was not installed and has been installed
|
||||
- `pkg replacement` - Netbird was installed via package manager and converted to binary install
|
||||
- `updated` - Netbird was updated to a newer version
|
||||
- `up to date` - Netbird was already at the latest version
|
||||
- **List of failed hosts** that could not be reached or had errors
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ✓ Deployment Summary
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ✓ Successful: 3
|
||||
║ ✗ Failed: 1
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Successful Hosts:
|
||||
✓ host-01.example.com (updated)
|
||||
✓ host-02.example.com (fresh install)
|
||||
✓ host-03.example.com (up to date)
|
||||
|
||||
Failed Hosts:
|
||||
✗ host-04.example.com
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: Success
|
||||
- `1`: Error (file not found, SSH failure, etc.)
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Tested Package Managers
|
||||
|
||||
- apt (Debian/Ubuntu)
|
||||
- yum (RHEL/CentOS 7)
|
||||
- dnf (Fedora/RHEL 8+/CentOS 8+)
|
||||
- zypper (openSUSE)
|
||||
- pacman (Arch Linux)
|
||||
- rpm-ostree (Fedora Silverblue)
|
||||
|
||||
### SELinux Support
|
||||
|
||||
Automatically handles SELinux contexts on systems with SELinux enabled (RHEL, Fedora, CentOS). The `restorecon` command is safely ignored on systems without SELinux.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Script requires root/sudo access
|
||||
- Downloads official Netbird installer from `https://pkgs.netbird.io/install.sh`
|
||||
- All operations are logged to `/var/log/netbird-update.log`
|
||||
- No credentials are stored or transmitted
|
||||
- Temporary files are cleaned up automatically
|
||||
|
||||
## Limitations
|
||||
|
||||
- Linux only (no macOS or Windows support)
|
||||
- Requires SSH key authentication for remote deployment (password authentication is not supported)
|
||||
- Background updates cannot be cancelled once started
|
||||
|
||||
## License
|
||||
|
||||
This script is provided as-is for managing Netbird installations. Netbird itself is subject to its own license terms.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please ensure:
|
||||
- Script syntax is valid (`bash -n script.sh`)
|
||||
- Changes are tested on multiple Linux distributions
|
||||
- SELinux compatibility is maintained
|
||||
- No artifacts are left behind after execution
|
||||
|
||||
## Support
|
||||
|
||||
For Netbird-specific issues, refer to the [official Netbird documentation](https://docs.netbird.io).
|
||||
|
||||
For script-specific issues, check the troubleshooting section or review the logs at `/var/log/netbird-update.log`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Example hosts file for netbird-install-update.sh
|
||||
# Format: hostname [sudo_password]
|
||||
# One host per line, comments start with #
|
||||
# Host-specific passwords override --password flag
|
||||
|
||||
# Production servers (with host-specific sudo passwords)
|
||||
prod-web-01.jeremy.skynet MySudoPassword123
|
||||
prod-web-02.jeremy.skynet MySudoPassword123
|
||||
prod-db-01.jeremy.skynet MySudoPassword123
|
||||
|
||||
# Development servers (will use --password flag or prompt interactively)
|
||||
dev-app-01.jeremy.skynet
|
||||
dev-app-02.jeremy.skynet
|
||||
|
||||
# You can also use IP addresses
|
||||
# 192.168.1.100
|
||||
# 10.0.0.50 MyPassword
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Color codes for output formatting
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Icons
|
||||
ICON_SUCCESS="✓"
|
||||
ICON_ERROR="✗"
|
||||
ICON_INFO="ℹ"
|
||||
ICON_WARNING="⚠"
|
||||
ICON_ARROW="→"
|
||||
ICON_DOWNLOAD="⬇"
|
||||
ICON_UPLOAD="⬆"
|
||||
ICON_INSTALL="📦"
|
||||
ICON_UPDATE="🔄"
|
||||
ICON_DEPLOY="🚀"
|
||||
ICON_TIMER="⏰"
|
||||
ICON_CHECK="🔍"
|
||||
ICON_CONFIG="⚙"
|
||||
ICON_NETWORK="🌐"
|
||||
ICON_VERSION="📋"
|
||||
ICON_LOG="📝"
|
||||
ICON_HOST="🖥"
|
||||
|
||||
SSH_HOST=""
|
||||
HOSTS_FILE=""
|
||||
INSTALL_TIMER=false
|
||||
TIMER_TIME="daily"
|
||||
DEFAULT_PASSWORD=""
|
||||
REMOTE_ARGS=()
|
||||
SSH_TIMEOUT=30
|
||||
|
||||
# Arrays to track deployment results
|
||||
declare -a SUCCESS_HOSTS=()
|
||||
declare -a FAILED_HOSTS=()
|
||||
declare -a HOST_ACTIONS=()
|
||||
|
||||
# Helper functions for formatted output
|
||||
print_header() {
|
||||
echo -e "\n${BLUE}${BOLD}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}${BOLD}║ $1${NC}"
|
||||
echo -e "${BLUE}${BOLD}╚═══════════════════════════════════════════════════════════════╝${NC}\n"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e " ${BLUE}${ICON_INFO}${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e " ${RED}${ICON_ERROR}${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} $1"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
echo -e " ${CYAN}${ICON_ARROW}${NC} $1"
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e " ${DIM}•${NC} $1"
|
||||
}
|
||||
|
||||
print_usage() {
|
||||
cat <<EOF
|
||||
Usage: netbird-install-update.sh [options]
|
||||
|
||||
Install, update, and manage Netbird across single or multiple Linux hosts.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message and exit
|
||||
--ssh <host> Deploy to a single host via SSH
|
||||
--hosts-file, --file <f> Deploy to multiple hosts from a file
|
||||
--timer Install a systemd auto-update timer
|
||||
--time <schedule> Timer schedule (systemd OnCalendar format, default: daily)
|
||||
-p, --password <pass> Sudo password for remote hosts (used with --ssh or --hosts-file)
|
||||
|
||||
Hosts file format (one per line):
|
||||
hostname_or_ip [sudo_password]
|
||||
|
||||
Examples:
|
||||
# Local install/update
|
||||
sudo ./netbird-install-update.sh
|
||||
|
||||
# Deploy to a single host via SSH
|
||||
./netbird-install-update.sh --ssh user@hostname
|
||||
|
||||
# Deploy to multiple hosts from a file
|
||||
./netbird-install-update.sh --hosts-file hosts.txt
|
||||
|
||||
# Deploy with auto-update timer
|
||||
./netbird-install-update.sh --ssh user@hostname --timer --time "weekly"
|
||||
|
||||
# Multi-host deployment with default sudo password
|
||||
./netbird-install-update.sh --hosts-file hosts.txt --password "mypass"
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
print_usage
|
||||
;;
|
||||
--ssh)
|
||||
SSH_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--hosts-file|--file)
|
||||
HOSTS_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--timer)
|
||||
INSTALL_TIMER=true
|
||||
REMOTE_ARGS+=("--timer")
|
||||
shift
|
||||
;;
|
||||
--time)
|
||||
TIMER_TIME="$2"
|
||||
REMOTE_ARGS+=("--time" "$2")
|
||||
shift 2
|
||||
;;
|
||||
--password|-p)
|
||||
DEFAULT_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
deploy_to_host() {
|
||||
local host="$1"
|
||||
local sudo_pass="${2:-}"
|
||||
echo -e "${CYAN}${BOLD}┌─────────────────────────────────────────────────────────────┐${NC}"
|
||||
echo -e "${CYAN}${BOLD}│${NC} ${ICON_DEPLOY} ${YELLOW}${host}${NC}"
|
||||
echo -e "${CYAN}${BOLD}└─────────────────────────────────────────────────────────────┘${NC}"
|
||||
|
||||
REMOTE_SCRIPT="/tmp/.netbird-install-update-$$"
|
||||
REMOTE_ACTION_FILE="/tmp/.netbird-action-$$"
|
||||
LOCAL_ACTION_FILE="/tmp/.netbird-deploy-action-$$"
|
||||
|
||||
print_step "Copying script..."
|
||||
if ! scp -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes "$0" "$host:$REMOTE_SCRIPT" 2>&1; then
|
||||
print_error "Failed to copy script to $host (timeout or connection error)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_step "Executing script..."
|
||||
# Build SSH command based on whether we have a password
|
||||
if [ -n "$sudo_pass" ]; then
|
||||
# Write password to remote temp file using printf to handle special characters
|
||||
# Use base64 to safely transfer the password
|
||||
local encoded_pass=$(echo -n "$sudo_pass" | base64)
|
||||
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "echo '$encoded_pass' | base64 -d > /tmp/.netbird-pass-$$ && chmod 600 /tmp/.netbird-pass-$$" 2>&1; then
|
||||
print_error "Failed to setup password file on $host"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Execute script with sudo, reading password from file
|
||||
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "sudo -S bash $REMOTE_SCRIPT ${REMOTE_ARGS[*]} < /tmp/.netbird-pass-$$" 2>&1; then
|
||||
print_error "Failed to execute script on $host"
|
||||
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE /tmp/.netbird-pass-$$" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Clean up password file
|
||||
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f /tmp/.netbird-pass-$$" 2>/dev/null || true
|
||||
else
|
||||
# Interactive sudo with terminal
|
||||
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes -t "$host" "sudo bash $REMOTE_SCRIPT ${REMOTE_ARGS[*]}" 2>&1; then
|
||||
print_error "Failed to execute script on $host"
|
||||
ssh -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read the action from the remote action file and write to local action file
|
||||
if action_output=$(ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "cat $REMOTE_ACTION_FILE 2>/dev/null" < /dev/null 2>&1); then
|
||||
echo "$action_output" > "$LOCAL_ACTION_FILE"
|
||||
else
|
||||
echo "unknown" > "$LOCAL_ACTION_FILE"
|
||||
fi
|
||||
|
||||
# Clean up remote files
|
||||
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE" < /dev/null 2>/dev/null || true
|
||||
print_success "Completed: $host"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ -n "$HOSTS_FILE" ]; then
|
||||
if [ ! -f "$HOSTS_FILE" ]; then
|
||||
print_error "Hosts file not found: $HOSTS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_header "${ICON_NETWORK} Multi-Host Deployment"
|
||||
print_info "Reading hosts from: ${YELLOW}$HOSTS_FILE${NC}"
|
||||
print_info "SSH timeout: ${YELLOW}${SSH_TIMEOUT}s${NC}"
|
||||
echo ""
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
ACTION_FILE="/tmp/.netbird-deploy-action-$$"
|
||||
|
||||
# Use file descriptor 3 to read hosts file (prevents SSH from consuming stdin)
|
||||
exec 3< "$HOSTS_FILE"
|
||||
while IFS= read -r line <&3 || [ -n "$line" ]; do
|
||||
# Skip empty lines and comments
|
||||
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
# Trim whitespace
|
||||
line=$(echo "$line" | xargs)
|
||||
|
||||
# Parse hostname and optional password
|
||||
host=$(echo "$line" | awk '{print $1}')
|
||||
sudo_pass=$(echo "$line" | awk '{print $2}')
|
||||
|
||||
# Use default password if no host-specific password is provided
|
||||
if [ -z "$sudo_pass" ] && [ -n "$DEFAULT_PASSWORD" ]; then
|
||||
sudo_pass="$DEFAULT_PASSWORD"
|
||||
fi
|
||||
|
||||
# Run deployment (output streams to terminal)
|
||||
if deploy_to_host "$host" "$sudo_pass"; then
|
||||
# Read the action from the temp file
|
||||
action="unknown"
|
||||
if [ -f "$ACTION_FILE" ]; then
|
||||
action=$(cat "$ACTION_FILE")
|
||||
rm -f "$ACTION_FILE"
|
||||
fi
|
||||
SUCCESS_HOSTS+=("$host")
|
||||
HOST_ACTIONS+=("$action")
|
||||
((SUCCESS_COUNT++)) || true
|
||||
else
|
||||
FAILED_HOSTS+=("$host")
|
||||
((FAIL_COUNT++)) || true
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
exec 3<&-
|
||||
|
||||
# Print detailed summary
|
||||
echo -e "\n${GREEN}${BOLD}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${ICON_SUCCESS} ${GREEN}${BOLD}Deployment Summary${NC}"
|
||||
echo -e "${GREEN}${BOLD}╠═══════════════════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${ICON_SUCCESS}${NC} Successful: ${BOLD}$SUCCESS_COUNT${NC}"
|
||||
echo -e "${GREEN}${BOLD}║${NC} ${RED}${ICON_ERROR}${NC} Failed: ${BOLD}$FAIL_COUNT${NC}"
|
||||
echo -e "${GREEN}${BOLD}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# Show successful hosts with actions
|
||||
if [ ${#SUCCESS_HOSTS[@]} -gt 0 ]; then
|
||||
echo -e "\n${GREEN}${BOLD}Successful Hosts:${NC}"
|
||||
for i in "${!SUCCESS_HOSTS[@]}"; do
|
||||
host="${SUCCESS_HOSTS[$i]}"
|
||||
action="${HOST_ACTIONS[$i]}"
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${YELLOW}$host${NC} ${DIM}($action)${NC}"
|
||||
done
|
||||
fi
|
||||
|
||||
# Show failed hosts
|
||||
if [ ${#FAILED_HOSTS[@]} -gt 0 ]; then
|
||||
echo -e "\n${RED}${BOLD}Failed Hosts:${NC}"
|
||||
for host in "${FAILED_HOSTS[@]}"; do
|
||||
echo -e " ${RED}${ICON_ERROR}${NC} ${YELLOW}$host${NC}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SSH_HOST" ]; then
|
||||
print_header "${ICON_DEPLOY} Single Host Deployment"
|
||||
ACTION_FILE="/tmp/.netbird-deploy-action-$$"
|
||||
if deploy_to_host "$SSH_HOST" "$DEFAULT_PASSWORD"; then
|
||||
# Read and display the action
|
||||
if [ -f "$ACTION_FILE" ]; then
|
||||
action=$(cat "$ACTION_FILE")
|
||||
rm -f "$ACTION_FILE"
|
||||
print_info "Action performed: ${YELLOW}$action${NC}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_warning "Elevating to root..."
|
||||
exec sudo "$0" "$@"
|
||||
fi
|
||||
|
||||
WORK_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
detect_install() {
|
||||
if ! command -v netbird &>/dev/null; then
|
||||
echo "none"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f /etc/netbird/install.conf ]; then
|
||||
local pm
|
||||
pm=$(grep -oP 'package_manager=\K.*' /etc/netbird/install.conf 2>/dev/null || true)
|
||||
if [ "$pm" = "bin" ]; then
|
||||
echo "bin"
|
||||
return
|
||||
elif [ -n "$pm" ]; then
|
||||
echo "pkg:$pm"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if dpkg -s netbird &>/dev/null; then
|
||||
echo "pkg:apt"
|
||||
return
|
||||
fi
|
||||
if rpm -q netbird &>/dev/null; then
|
||||
if command -v dnf &>/dev/null; then
|
||||
echo "pkg:dnf"
|
||||
else
|
||||
echo "pkg:yum"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if pacman -Q netbird &>/dev/null; then
|
||||
echo "pkg:pacman"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "bin"
|
||||
}
|
||||
|
||||
install_timer() {
|
||||
print_header "${ICON_TIMER} Installing Auto-Update Timer"
|
||||
|
||||
if systemctl is-active --quiet netbird-update.timer; then
|
||||
print_warning "Updating existing timer..."
|
||||
systemctl stop netbird-update.timer
|
||||
systemctl disable netbird-update.timer
|
||||
fi
|
||||
|
||||
cat > /etc/systemd/system/netbird-update.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Netbird Auto-Update
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash -c 'curl -fsSL https://pkgs.netbird.io/install.sh | UPDATE_NETBIRD=true sh && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true && systemctl restart netbird 2>/dev/null || true'
|
||||
StandardOutput=append:/var/log/netbird-update.log
|
||||
StandardError=append:/var/log/netbird-update.log
|
||||
UNIT
|
||||
|
||||
cat > /etc/systemd/system/netbird-update.timer <<UNIT
|
||||
[Unit]
|
||||
Description=Run Netbird update daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=$TIMER_TIME
|
||||
Persistent=true
|
||||
RandomizedDelaySec=600
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now netbird-update.timer
|
||||
|
||||
print_success "Timer installed successfully"
|
||||
print_info "Schedule: ${YELLOW}$TIMER_TIME${NC} (with random 0-10 minute delay)"
|
||||
print_info "Logs: ${YELLOW}/var/log/netbird-update.log${NC}"
|
||||
}
|
||||
|
||||
print_header "${ICON_INSTALL} NetBird Install/Update"
|
||||
|
||||
print_status "${ICON_DOWNLOAD} Downloading Netbird install script..."
|
||||
curl -fsSL -o "$WORK_DIR/install.sh" https://pkgs.netbird.io/install.sh
|
||||
chmod +x "$WORK_DIR/install.sh"
|
||||
print_success "Download complete"
|
||||
|
||||
print_status "${ICON_CHECK} Detecting installation state..."
|
||||
state=$(detect_install)
|
||||
print_info "Detected: ${YELLOW}$state${NC}"
|
||||
echo ""
|
||||
|
||||
case "$state" in
|
||||
none)
|
||||
print_header "${ICON_INSTALL} Fresh Installation"
|
||||
print_info "Netbird not found. Installing..."
|
||||
USE_BIN_INSTALL=true SKIP_UI_APP=true "$WORK_DIR/install.sh"
|
||||
chown root:root /usr/bin/netbird 2>/dev/null || true
|
||||
restorecon -v /usr/bin/netbird 2>/dev/null || true
|
||||
systemctl restart netbird 2>/dev/null || true
|
||||
print_success "Netbird installed successfully"
|
||||
echo "fresh install" > /tmp/.netbird-action-$$
|
||||
;;
|
||||
pkg:*)
|
||||
print_header "${ICON_UPDATE} Package Manager Replacement"
|
||||
pm="${state#pkg:}"
|
||||
print_info "Netbird installed via ${YELLOW}$pm${NC}"
|
||||
print_info "Replacing with binary install..."
|
||||
cat > /tmp/.netbird_replace.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sleep 3
|
||||
systemctl stop netbird 2>/dev/null || true
|
||||
case "$1" in
|
||||
apt) apt-get remove -y netbird netbird-ui 2>/dev/null || true ;;
|
||||
yum) yum remove -y netbird netbird-ui 2>/dev/null || true ;;
|
||||
dnf) dnf remove -y netbird netbird-ui 2>/dev/null || true ;;
|
||||
zypper) zypper remove -y netbird netbird-ui 2>/dev/null || true ;;
|
||||
rpm-ostree) rpm-ostree uninstall -y netbird netbird-ui 2>/dev/null || true ;;
|
||||
pacman) pacman -Rns --noconfirm netbird netbird-ui 2>/dev/null || true ;;
|
||||
esac
|
||||
USE_BIN_INSTALL=true SKIP_UI_APP=true bash /tmp/.netbird_install.sh
|
||||
chown root:root /usr/bin/netbird 2>/dev/null || true
|
||||
chmod +x /usr/bin/netbird 2>/dev/null || true
|
||||
restorecon -v /usr/bin/netbird 2>/dev/null || true
|
||||
systemctl restart netbird 2>/dev/null || true
|
||||
rm -f /tmp/.netbird_replace.sh /tmp/.netbird_install.sh
|
||||
EOF
|
||||
chmod +x /tmp/.netbird_replace.sh
|
||||
cp "$WORK_DIR/install.sh" /tmp/.netbird_install.sh
|
||||
setsid bash /tmp/.netbird_replace.sh "$pm" </dev/null >/var/log/netbird-update.log 2>&1 &
|
||||
print_warning "Replacement scheduled in background"
|
||||
print_info "Connection may drop momentarily"
|
||||
print_info "Check ${YELLOW}/var/log/netbird-update.log${NC} for progress"
|
||||
echo "pkg replacement" > /tmp/.netbird-action-$$
|
||||
;;
|
||||
bin)
|
||||
print_header "${ICON_VERSION} Binary Update Check"
|
||||
if [ ! -f /etc/netbird/install.conf ]; then
|
||||
mkdir -p /etc/netbird
|
||||
echo "package_manager=bin" > /etc/netbird/install.conf
|
||||
fi
|
||||
current=$(netbird version 2>/dev/null || echo "unknown")
|
||||
latest=$(curl -fsSL https://pkgs.netbird.io/releases/latest 2>/dev/null | grep -oP '"tag_name":\s*"v?\K[^"]+' || echo "unknown")
|
||||
|
||||
print_info "Current version: ${YELLOW}$current${NC}"
|
||||
print_info "Latest version: ${YELLOW}$latest${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$current" = "$latest" ] && [ "$current" != "unknown" ]; then
|
||||
print_success "Netbird is up to date"
|
||||
echo "up to date" > /tmp/.netbird-action-$$
|
||||
else
|
||||
print_info "Updating Netbird: ${YELLOW}$current${NC} → ${GREEN}$latest${NC}"
|
||||
cp "$WORK_DIR/install.sh" /tmp/.netbird_update.sh
|
||||
setsid bash -c 'sleep 3 && bash /tmp/.netbird_update.sh --update && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true && systemctl restart netbird 2>/dev/null || true && rm -f /tmp/.netbird_update.sh' \
|
||||
</dev/null >/var/log/netbird-update.log 2>&1 &
|
||||
print_warning "Update scheduled in background"
|
||||
print_info "Connection may drop momentarily"
|
||||
print_info "Check ${YELLOW}/var/log/netbird-update.log${NC} for progress"
|
||||
echo "updated" > /tmp/.netbird-action-$$
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if $INSTALL_TIMER; then
|
||||
install_timer
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -0,0 +1,16 @@
|
||||
# Test SSH remote execution
|
||||
echo "=== Testing SSH remote execution ==="
|
||||
echo ""
|
||||
echo "Usage examples:"
|
||||
echo " ./netbird-install-update.sh # Run locally"
|
||||
echo " ./netbird-install-update.sh --timer # Run locally with auto-update timer"
|
||||
echo " ./netbird-install-update.sh --ssh user@host # Deploy to remote host"
|
||||
echo " ./netbird-install-update.sh --ssh user@host --timer # Deploy to remote host with timer"
|
||||
echo ""
|
||||
echo "The -ssh flag will:"
|
||||
echo "1. Copy this script to the remote host via scp"
|
||||
echo "2. Execute it remotely with sudo"
|
||||
echo "3. Pass through any other flags (like --timer)"
|
||||
echo "4. Clean up the remote copy after execution"
|
||||
echo ""
|
||||
echo "Script is ready for testing!"
|
||||
+126
-6
@@ -1,12 +1,132 @@
|
||||
# Zsh Config
|
||||
|
||||
A reproducible, self-configuring Zsh setup managed from a remote git repo. The `zshrc` auto-updates itself and the Starship prompt config on every interactive shell start, and a versioned bootstrap script installs all missing dependencies across distros. Drop `~/.zshrc` in place once and every machine stays in sync automatically.
|
||||
|
||||
## Design
|
||||
|
||||
- **Reproducible** — one `zshrc` drives an identical environment across every machine and distro.
|
||||
- **Self-configuring** — tool integrations are guarded by `command -v`, so the shell works degraded-but-fine when a tool is absent, and lights up automatically once the bootstrap installs it.
|
||||
- **Self-updating** — on each interactive shell start, `zshrc` and `starship.toml` are re-pulled from the repo and applied if changed (downloaded files are validated with `zsh -n` before replacing anything, so a 502 error page or corrupted content can't break the shell).
|
||||
- **Network-resilient** — all remote calls use `--fail` + `--connect-timeout`/`--max-time`, so an unreachable server silently no-ops instead of hanging the shell.
|
||||
|
||||
## Files
|
||||
|
||||
### `zshrc`
|
||||
Main Zsh configuration file. Sets up Oh My Zsh, plugins (git, sudo, eza, fzf, starship, etc.), history options, aliases, and auto-updates itself from a remote source. Before replacing `~/.zshrc`, the downloaded file is validated with `zsh -n` to ensure it's syntactically valid — this prevents error pages or corrupted content from breaking the shell. Also bootstraps tmux config and runs a fastfetch system info display on interactive shells.
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`zshrc`](zshrc) | Main config. Path setup, history options, oh-my-zsh + plugins, eza theme, tool integrations, aliases, and the auto-update logic. Sources `~/.zshrc.local` at the end for per-machine overrides. |
|
||||
| [`zshrc-bootstrap.zsh`](zshrc-bootstrap.zsh) | One-shot setup script run by `zshrc` when the bootstrap version changes. Installs all dependencies across distros and clones oh-my-zsh + custom plugins. |
|
||||
| [`starship.toml`](starship.toml) | Starship prompt config — two-line prompt with directory, git status, command duration, Nerd Font symbols, and an error indicator. Synced to `~/.config/starship.toml` by the auto-update logic. |
|
||||
| [`tmux.conf`](tmux.conf) | Tmux config with GitHub Dark theming, Vim-style pane navigation (`h/j/k/l`), TPM plugin manager, Nerd Font auto-install, and a built-in cheatsheet (`prefix + ?`). Installed to `~/.config/tmux/tmux.conf` on first shell start. |
|
||||
|
||||
### `zshrc-bootstrap.zsh`
|
||||
One-shot setup script executed by `zshrc`. Installs missing dependencies (starship, lazydocker, fastfetch, eza, fzf, etc.) via the system package manager (brew, apt, dnf, pacman) or cargo, clones Oh My Zsh and custom plugins (zsh-autosuggestions, fast-syntax-highlighting, fzf-tab). Runs only when the bootstrap version changes.
|
||||
## Getting Started
|
||||
|
||||
### `tmux.conf`
|
||||
Tmux configuration with GitHub Dark theming, Nerd Font auto-install, TPM plugin manager, pane/window keybindings (Vim-style navigation with `h/j/k/l`), status bar showing session info, battery, online status, and a built-in cheatsheet (bound to `prefix + ?`).
|
||||
Point `~/.zshrc` at the remote `zshrc`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.jeremymcclure.com/jeremy/scripts/raw/branch/master/zsh/zshrc -o ~/.zshrc && exec zsh
|
||||
```
|
||||
|
||||
On first start, `zshrc` bootstraps missing dependencies (oh-my-zsh, plugins, and all CLI tools below) over the next shell starts. To force a re-bootstrap (e.g., after adding new tool installs to the bootstrap script), the bootstrap version is bumped in `zshrc` and every machine re-runs it automatically — or run manually:
|
||||
|
||||
```bash
|
||||
BOOTSTRAP=true exec zsh
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
All installed automatically by the bootstrap across brew / apt / dnf / pacman / cargo:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| starship | Prompt (configured via `starship.toml`) |
|
||||
| fzf | Fuzzy finder — powers `fzf-tab` completion and `zi` |
|
||||
| eza | `ls` replacement with icons, git status, hyperlinks |
|
||||
| bat | `cat` replacement + colored man pages |
|
||||
| zoxide | Frecency directory jumping (`z`, `zi`) |
|
||||
| git-delta | Pretty `git diff`/`log`/`show` pager |
|
||||
| fastfetch | System info on shell start |
|
||||
| lazydocker | TUI for Docker |
|
||||
| rsync / tmux / git | Core utilities |
|
||||
|
||||
Debian/Ubuntu renames handled: `batcat`→`bat`, `fdfind`→`fd` (symlinked into `~/.local/bin`).
|
||||
|
||||
## Oh-My-Zsh Plugins
|
||||
|
||||
`git`, `sudo`, `extract`, `eza`, `history`, `kitty`, `docker`, `docker-compose`, `archlinux`, `encode64`, `universalarchive`, `zsh-autosuggestions`, `fast-syntax-highlighting`, `fzf`, `fzf-tab`, `systemd`, `vscode`, `rsync`, `starship`.
|
||||
|
||||
## Tool Integrations
|
||||
|
||||
Each is enabled only when the binary is present (`command -v`):
|
||||
|
||||
- **zoxide** — `z <substring>` jumps to frecency-ranked dirs; `zi` opens the fzf picker. `cd` still works.
|
||||
- **bat** — `alias cat='bat'`, `BAT_THEME=Monokai Extended`, and `MANPAGER` set so `man` pages are colored and paginated by bat.
|
||||
- **delta** — `GIT_PAGER=delta` so `git diff`/`log`/`show` render side-by-side with syntax highlighting. (Not aliased to `diff` — delta is a pager reading diff input on stdin, not a `diff a b` replacement.)
|
||||
|
||||
## History & Shell Options
|
||||
|
||||
`SHARE_HISTORY`, `HIST_IGNORE_DUPS`, `HIST_IGNORE_ALL_DUPS`, `HIST_REDUCE_BLANKS`, `HIST_FCNTL_LOCK`, `HIST_VERIFY`, `EXTENDED_HISTORY`, `AUTO_CD`, `AUTO_LIST`, `INTERACTIVE_COMMENTS`, `AUTO_PUSHD`, `PUSHD_IGNORE_DUPS`, `PUSHD_SILENT`.
|
||||
|
||||
`AUTO_PUSHD` maintains an automatic directory stack, so `cd -<TAB>` and `dirs` let you navigate recent locations without `pushd`/`popd` muscle memory.
|
||||
|
||||
## Aliases
|
||||
|
||||
| Alias | Action |
|
||||
|-------|--------|
|
||||
| `c` | `clear` |
|
||||
| `q` | `exit` |
|
||||
| `open-ports` | `ss -tulpn \| grep LISTEN` |
|
||||
| `nbstat` | Netbird peer status as a TSV table |
|
||||
| `yeet` | `yay -Rcs` (remove + deps + config on Arch) |
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+R` | fzf history search (via `fzf` plugin) |
|
||||
| `Ctrl+T` | fzf file finder |
|
||||
| `Alt+C` | fzf cd |
|
||||
| `Tab` | `fzf-tab` completion menu |
|
||||
| `Esc` twice | Toggle `sudo` prefix (via `sudo` plugin) |
|
||||
|
||||
## Eza Hyperlinks — Implementation Note
|
||||
|
||||
`zshrc` does **not** set the oh-my-zsh `eza` plugin's `hyperlink` zstyle (the plugin's bare `--hyperlink` causes issues). Instead, after oh-my-zsh loads, the aliases are rewritten to append the correct hyperlink flag for the installed eza version:
|
||||
|
||||
- **Newer eza** (help shows `--hyperlink [<WHEN>]`): the oh-my-zsh plugin's bare `--hyperlink` is treated by clap as taking the *next token* as its optional value, so `la /` fails with `invalid value '/'`. We append the glued `--hyperlink=always` form, which can't eat a following path argument.
|
||||
- **Older eza** (e.g. Debian's, where `--hyperlink` is a pure boolean): `--hyperlink=always` is rejected with `Flag --hyperlink cannot take a value`. We detect this via `eza --help` and fall back to the bare `--hyperlink`, which is safe here because boolean flags don't consume the next token (the path-eating bug doesn't apply).
|
||||
|
||||
Detection runs once at shell start via `eza --help | grep '<WHEN>'`; the right form is applied to every eza alias. Hyperlinks stay on and paths keep working across eza versions.
|
||||
|
||||
## Auto-Update Behavior
|
||||
|
||||
On every interactive shell start:
|
||||
|
||||
1. **zshrc** — re-downloaded, validated with `zsh -n`, and applied via `exec zsh` only if it differs from `~/.zshrc`.
|
||||
2. **starship.toml** — re-downloaded and moved to `~/.config/starship.toml` if changed (applied live on the next prompt render; no shell reload needed).
|
||||
3. **tmux.conf** — downloaded once on first shell start if `~/.config/tmux/tmux.conf` and `~/.config/byobu/.tmux.conf` are both absent.
|
||||
4. **bootstrap** — runs (`zshrc-bootstrap.zsh`) when `ZSHRC_BOOTSTRAP_VERSION` in `zshrc` doesn't match `~/.config/zsh/.bootstrapped`.
|
||||
|
||||
All downloads use `--fail` so a 502/HTML error page is never written over a working config, and `--connect-timeout`/`--max-time` so an unreachable server never hangs startup.
|
||||
|
||||
## Per-Machine Overrides
|
||||
|
||||
`~/.zshrc.local` is sourced at the very end of `zshrc` (created empty if missing). Put machine-specific aliases, env vars, or PATH additions there — they survive repo updates since `zshrc` doesn't manage that file.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ZSHRC_GIT` | `.../scripts/raw/branch/master/zsh/` | Base URL for all remote config files |
|
||||
| `ZSHRC_BOOTSTRAP_VERSION` | `4` | Bump to force a re-bootstrap on all machines |
|
||||
| `FFENABLED` | (unset → enabled) | Set to `false` to disable the fastfetch system info on shell start |
|
||||
| `BAT_THEME` | `Monokai Extended` | Override before the bat block to use a different bat theme |
|
||||
| `GIT_PAGER` | `delta` (when installed) | Override to use a different git pager |
|
||||
|
||||
## Helper Functions
|
||||
|
||||
```bash
|
||||
update_zshrc # Manually re-pull and reload the zshrc
|
||||
fix_btopbg # Disable btop theme_background (workaround for transparency)
|
||||
install_opencode # Install the opencode CLI
|
||||
BOOTSTRAP=true exec zsh # Force a re-bootstrap
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Starship prompt configuration - managed by the zshrc repo.
|
||||
# Local edits to ~/.config/starship.toml are overwritten on the next interactive
|
||||
# shell start (reproducible/self-configuring design). Uses Nerd Font symbols
|
||||
# (JetBrainsMono Nerd Font is installed by the tmux config).
|
||||
|
||||
command_timeout = 1000
|
||||
add_newline = true
|
||||
|
||||
# Two-line prompt:
|
||||
# line 1: directory + git status + command duration
|
||||
# line 2: the input arrow (red on error)
|
||||
format = """
|
||||
$directory\
|
||||
$git_branch\
|
||||
$git_status\
|
||||
$cmd_duration\
|
||||
$line_break\
|
||||
$character\
|
||||
"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[➜](bold green)"
|
||||
error_symbol = "[✗](bold red)"
|
||||
vimcmd_symbol = "[V](bold green)"
|
||||
|
||||
[directory]
|
||||
truncation_length = 3
|
||||
truncate_to_repo = true
|
||||
style = "bold blue"
|
||||
read_only = " "
|
||||
read_only_style = "red"
|
||||
repo_root_style = "bold cyan"
|
||||
|
||||
[git_branch]
|
||||
symbol = " "
|
||||
style = "bold purple"
|
||||
format = "[$symbol$branch]($style) "
|
||||
|
||||
[git_status]
|
||||
style = "bold red"
|
||||
conflicted = "=${count}"
|
||||
ahead = "⇡${count}"
|
||||
behind = "⇣${count}"
|
||||
diverged = "⇕↑${ahead_count}↓${behind_count}"
|
||||
untracked = "?${count}"
|
||||
stashed = " *${count}"
|
||||
modified = " !${count}"
|
||||
staged = " +${count}"
|
||||
renamed = " »${count}"
|
||||
deleted = " ✘${count}"
|
||||
format = '([$all_status$ahead_behind]($style) )'
|
||||
|
||||
[cmd_duration]
|
||||
min_time = 2000
|
||||
format = "took [$duration](bold yellow) "
|
||||
|
||||
[status]
|
||||
disabled = false
|
||||
format = '[$symbol]($style)'
|
||||
symbol = "✗ "
|
||||
success_symbol = ""
|
||||
style = "bold red"
|
||||
|
||||
[username]
|
||||
show_always = false
|
||||
style_user = "bold yellow"
|
||||
style_root = "bold red"
|
||||
format = "[$user]($style)@"
|
||||
|
||||
[hostname]
|
||||
ssh_only = true
|
||||
style = "bold green"
|
||||
format = "[$hostname]($style) "
|
||||
|
||||
[python]
|
||||
symbol = " "
|
||||
format = '[${symbol}${pyenv_prefix}(${version})(\($virtualenv\))]($style) '
|
||||
|
||||
[nodejs]
|
||||
symbol = " "
|
||||
format = "[$symbol($version)]($style) "
|
||||
|
||||
[rust]
|
||||
symbol = " "
|
||||
format = "[$symbol($version)]($style) "
|
||||
|
||||
[golang]
|
||||
symbol = " "
|
||||
format = "[$symbol($version)]($style) "
|
||||
|
||||
[java]
|
||||
symbol = " "
|
||||
format = "[$symbol($version)]($style) "
|
||||
|
||||
[docker_context]
|
||||
symbol = " "
|
||||
format = "[$symbol$context]($style) "
|
||||
|
||||
[shell]
|
||||
disabled = true
|
||||
style = "bold cyan"
|
||||
@@ -6,7 +6,7 @@ export ZSHRC_GIT="https://git.jeremymcclure.com/jeremy/scripts/raw/branch/master
|
||||
|
||||
export ZSHRC_URL="$ZSHRC_GIT/zshrc"
|
||||
export ZSHRC_BOOTSTRAP_URL="$ZSHRC_GIT/zshrc-bootstrap.zsh"
|
||||
export ZSHRC_BOOTSTRAP_VERSION="2"
|
||||
export ZSHRC_BOOTSTRAP_VERSION="4"
|
||||
export ZSHRC_BOOTSTRAP="$ZSH_CONFIG/.bootstrapped"
|
||||
|
||||
[[ -d "$HOME/Scripts" ]] && path+=("$HOME/Scripts")
|
||||
@@ -16,7 +16,7 @@ export ZSHRC_BOOTSTRAP="$ZSH_CONFIG/.bootstrapped"
|
||||
|
||||
update_zshrc(){
|
||||
local tmp=/tmp/.zshrc.$$
|
||||
curl -sSL --connect-timeout 5 "$ZSHRC_URL" -o "$tmp" 2>/dev/null || return
|
||||
curl -fsSL --connect-timeout 5 --max-time 10 "$ZSHRC_URL" -o "$tmp" 2>/dev/null || return
|
||||
if zsh -n "$tmp" 2>/dev/null && ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then
|
||||
echo "Updating zshrc..."
|
||||
mv "$tmp" "$HOME/.zshrc" && echo "Updated. Reloading..." && exec zsh
|
||||
@@ -32,10 +32,13 @@ install_opencode(){
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
}
|
||||
|
||||
# Bootstrap: download and run once per bootstrap version
|
||||
# Bootstrap: download and run once per bootstrap version. Installs missing CLI
|
||||
# tools and oh-my-zsh/plugins across distros; prints a banner so the user knows
|
||||
# what the activity is. Re-runs only when ZSHRC_BOOTSTRAP_VERSION is bumped or
|
||||
# BOOTSTRAP=true is set.
|
||||
if [[ ! -f "$ZSHRC_BOOTSTRAP" || "$(cat "$ZSHRC_BOOTSTRAP" 2>/dev/null)" != "$ZSHRC_BOOTSTRAP_VERSION" || "$BOOTSTRAP" == "true" ]]; then
|
||||
tmp=/tmp/.zshrc_bootstrap.$$
|
||||
curl -sSL --connect-timeout 5 -o "$tmp" "$ZSHRC_BOOTSTRAP_URL" 2>/dev/null
|
||||
curl -fsSL --connect-timeout 5 --max-time 10 -o "$tmp" "$ZSHRC_BOOTSTRAP_URL" 2>/dev/null
|
||||
if [[ -f "$tmp" ]]; then
|
||||
zsh "$tmp" 2>/dev/null
|
||||
echo "$ZSHRC_BOOTSTRAP_VERSION" > "$ZSHRC_BOOTSTRAP"
|
||||
@@ -46,13 +49,13 @@ fi
|
||||
TMUXCONF="$HOME/.config/tmux"
|
||||
BYOBUCONF="$HOME/.config/byobu"
|
||||
TMUXREMOTE="$ZSHRC_GIT/tmux.conf"
|
||||
if [ ! -f "$TMUXCONF" ]; then
|
||||
if [ ! -f "$TMUXCONF/tmux.conf" ] && [ ! -f "$BYOBUCONF/.tmux.conf" ]; then
|
||||
# Create a secure temporary file
|
||||
TEMP_TMUXCONF=$(mktemp)
|
||||
|
||||
# Download to temp file.
|
||||
# The '&&' ensures the 'mv' only runs if curl succeeds (exit code 0).
|
||||
if curl -sSL --connect-timeout 5 -o "$TEMP_TMUXCONF" "$TMUXREMOTE"; then
|
||||
if curl -fsSL --connect-timeout 5 --max-time 10 -o "$TEMP_TMUXCONF" "$TMUXREMOTE"; then
|
||||
mkdir -p "$TMUXCONF" "$BYOBUCONF"
|
||||
cp "$TEMP_TMUXCONF" "$TMUXCONF/tmux.conf"
|
||||
mv "$TEMP_TMUXCONF" "$BYOBUCONF/.tmux.conf"
|
||||
@@ -76,6 +79,11 @@ setopt HIST_FCNTL_LOCK
|
||||
setopt AUTO_CD
|
||||
setopt AUTO_LIST
|
||||
setopt INTERACTIVE_COMMENTS
|
||||
setopt HIST_VERIFY
|
||||
setopt EXTENDED_HISTORY
|
||||
setopt AUTO_PUSHD
|
||||
setopt PUSHD_IGNORE_DUPS
|
||||
setopt PUSHD_SILENT
|
||||
|
||||
ZSH_THEME=""
|
||||
ENABLE_CORRECTION="false"
|
||||
@@ -112,7 +120,10 @@ zstyle ':omz:plugins:eza' 'icons' yes
|
||||
zstyle ':omz:plugins:eza' 'color-scale' all
|
||||
zstyle ':omz:plugins:eza' 'color-scale-mode' fixed
|
||||
zstyle ':omz:plugins:eza' 'size-prefix' si
|
||||
zstyle ':omz:plugins:eza' 'hyperlink' yes
|
||||
# NOTE: 'hyperlink' is intentionally NOT set here. The oh-my-zsh eza plugin adds
|
||||
# a bare "--hyperlink" flag which eza (clap) treats as taking the next token as
|
||||
# its optional value, so `la /` fails with "invalid value '/'". We re-enable
|
||||
# hyperlinks safely below via the glued `--hyperlink=always` form.
|
||||
|
||||
zstyle ':completion:*' menu select
|
||||
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}'
|
||||
@@ -121,21 +132,74 @@ zstyle ':completion:*' special-dirs true
|
||||
|
||||
source $OHMYZSH/oh-my-zsh.sh
|
||||
|
||||
# Re-enable eza hyperlinks, picking the flag form the installed eza supports.
|
||||
# Newer eza: --hyperlink takes an optional value; the oh-my-zsh plugin's bare
|
||||
# --hyperlink would eat the following path arg (e.g. `la /` -> invalid value '/'),
|
||||
# so we use the glued --hyperlink=always form.
|
||||
# Older eza (e.g. Debian's): --hyperlink is a pure boolean and rejects a value,
|
||||
# so --hyperlink=always errors; the bare --hyperlink is correct and safe there
|
||||
# (boolean flags don't consume the next token, so the path-eating bug doesn't
|
||||
# apply).
|
||||
_hl_flag="--hyperlink"
|
||||
if command -v eza >/dev/null && eza --help 2>&1 | grep -q -- '--hyperlink.*<WHEN>'; then
|
||||
_hl_flag="--hyperlink=always"
|
||||
fi
|
||||
for _ea in la ldot lD lDD ll ls lsd lsdl lS lT; do
|
||||
(( $+aliases[$_ea] )) && alias "$_ea"="${aliases[$_ea]} $_hl_flag"
|
||||
done
|
||||
unset _ea _hl_flag
|
||||
|
||||
# --- Tool integrations (self-configuring: enabled only when the tool exists) ---
|
||||
|
||||
# zoxide: frecency-based directory jumping — `z <substring>` / `zi` (fzf picker).
|
||||
# Replaces nothing; `cd` still works. Auto-installed by the bootstrap.
|
||||
command -v zoxide >/dev/null && eval "$(zoxide init zsh)" 2>/dev/null
|
||||
|
||||
# bat: colored `cat` and colored man pages. bat/batcat handled by bootstrap.
|
||||
if command -v bat >/dev/null; then
|
||||
alias cat='bat'
|
||||
export BAT_THEME="${BAT_THEME:-Monokai Extended}"
|
||||
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
|
||||
export MANROFFOPT='-c'
|
||||
fi
|
||||
|
||||
# delta: prettier git diffs as git's pager (git respects GIT_PAGER). Auto-installed
|
||||
# by the bootstrap. Not aliased to `diff` since delta reads diff input on stdin,
|
||||
# not two file arguments — GIT_PAGER is the correct integration.
|
||||
command -v delta >/dev/null && export GIT_PAGER='delta'
|
||||
|
||||
alias c='clear'
|
||||
alias q="exit"
|
||||
alias nbstat=$'netbird status --json | jq -r \'.peers.details[]? | [(.hostname // .fqdn), .netbirdIp, .status] | @tsv\' | column -t -s $\'\\t\''
|
||||
alias open-ports="ss -tulpn | grep LISTEN"
|
||||
alias yeet='yay -Rcs'
|
||||
|
||||
# Auto-update: check every shell start, silently fail if server unreachable
|
||||
# Auto-update self and managed configs: checked every interactive shell start,
|
||||
# silently skipped if the server is unreachable. Keeps zshrc + starship.toml in
|
||||
# sync across machines (reproducible/self-configuring); only reloads the shell
|
||||
# when the zshrc itself changes (starship applies its config live per render).
|
||||
if [[ -o interactive ]]; then
|
||||
# zshrc (reload shell if changed)
|
||||
tmp=/tmp/.zshrc.$$
|
||||
curl -sSL --connect-timeout 3 "$ZSHRC_URL" -o "$tmp" 2>/dev/null
|
||||
if [[ -f "$tmp" ]] && zsh -n "$tmp" 2>/dev/null && ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then
|
||||
if curl -fsSL --connect-timeout 3 --max-time 8 "$ZSHRC_URL" -o "$tmp" 2>/dev/null \
|
||||
&& [[ -f "$tmp" ]] && zsh -n "$tmp" 2>/dev/null \
|
||||
&& ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then
|
||||
echo "Updating zshrc..."
|
||||
mv "$tmp" "$HOME/.zshrc" && exec zsh
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
|
||||
# starship prompt config (overwrite if changed; applied live on next render)
|
||||
if command -v starship >/dev/null; then
|
||||
stmp=$(mktemp)
|
||||
if curl -fsSL --connect-timeout 3 --max-time 8 "$ZSHRC_GIT/starship.toml" -o "$stmp" 2>/dev/null \
|
||||
&& [[ -f "$stmp" ]] && ! cmp -s "$stmp" "$HOME/.config/starship.toml" 2>/dev/null; then
|
||||
mkdir -p "$HOME/.config"
|
||||
mv "$stmp" "$HOME/.config/starship.toml"
|
||||
else
|
||||
rm -f "$stmp"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -f $HOME/.zshrc.local ]] || touch $HOME/.zshrc.local
|
||||
|
||||
+202
-31
@@ -2,71 +2,238 @@ export ZSH_CONFIG=$HOME/.config/zsh
|
||||
export OHMYZSH=$ZSH_CONFIG/oh-my-zsh
|
||||
export OHMYZSH_CUSTOM=$OHMYZSH/custom
|
||||
|
||||
# Bootstrap banner — built dynamically so the box stays aligned regardless
|
||||
# of the version string length or terminal width.
|
||||
_bootstrap_banner() {
|
||||
local inner=61 # inner width (between the vertical bars)
|
||||
local pad line
|
||||
local title="Zsh bootstrap - one-time environment setup (v${ZSHRC_BOOTSTRAP_VERSION})"
|
||||
local rule=$(printf '═%.0s' {1..$inner})
|
||||
local mid=$(printf '─%.0s' {1..$inner})
|
||||
# left-pad the title by 2 spaces so it doesn't hug the left bar
|
||||
title=" $title"
|
||||
pad=$(( inner - ${#title} ))
|
||||
(( pad < 0 )) && pad=0
|
||||
echo ""
|
||||
echo "╔${rule}╗"
|
||||
printf '║%s%*s║\n' "$title" "$pad" ""
|
||||
echo "╠${mid}╣"
|
||||
while IFS= read -r line; do
|
||||
line=" $line"
|
||||
pad=$(( inner - ${#line} ))
|
||||
(( pad < 0 )) && pad=0
|
||||
printf '║%s%*s║\n' "$line" "$pad" ""
|
||||
done <<'EOF'
|
||||
This runs on first login or when the bootstrap version
|
||||
changes. It installs missing CLI tools and clones oh-my-zsh
|
||||
+ plugins across distros. Network calls time out after
|
||||
~30s so an unreachable host skips silently. The first run
|
||||
may take a minute; subsequent runs are a no-op.
|
||||
EOF
|
||||
echo "╚${rule}╝"
|
||||
echo ""
|
||||
}
|
||||
_bootstrap_banner
|
||||
unfunction _bootstrap_banner
|
||||
|
||||
# GitHub-release binary fallback. Used for tools not packaged in a distro's
|
||||
# repos (e.g. bat/zoxide/git-delta on RHEL-family without EPEL, or no cargo).
|
||||
# Resolves the latest release tag via the /releases/latest redirect (no API,
|
||||
# no rate limits), builds the asset URL from a template, extracts, and copies
|
||||
# the named binary into ~/.local/bin. Args:
|
||||
# $1 owner/repo $2 asset template (%v = version, no leading v)
|
||||
# $3 binary name $4 arch suffix to substitute into %a in the template
|
||||
_gh_latest_tag() {
|
||||
local repo="$1"
|
||||
curl -fsIL --connect-timeout 5 --max-time 20 "https://github.com/$repo/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | tail -1 \
|
||||
| sed -E 's#.*tag/([^[:space:]"/]+).*#\1#'
|
||||
}
|
||||
|
||||
_gh_install_bin() {
|
||||
local repo="$1" tmpl="$2" bin="$3"
|
||||
local rawtag ver asset ext tmp found
|
||||
rawtag=$(_gh_latest_tag "$repo")
|
||||
if [[ -z "$rawtag" ]]; then
|
||||
echo " FAIL could not resolve latest tag for $repo"; return 1
|
||||
fi
|
||||
ver="${rawtag#v}" # strip optional leading v for asset-filename substitution
|
||||
asset="${tmpl//\%v/$ver}"
|
||||
ext="${asset##*.}"
|
||||
echo " gh installing $bin ($rawtag from $repo)"
|
||||
tmp=$(mktemp -d)
|
||||
if curl -fsSL --connect-timeout 5 --max-time 60 -o "$tmp/pkg.$ext" \
|
||||
"https://github.com/$repo/releases/download/$rawtag/$asset" \
|
||||
&& { case "$ext" in
|
||||
gz) tar -xzf "$tmp/pkg.$ext" -C "$tmp" 2>/dev/null ;;
|
||||
*) return 1 ;;
|
||||
esac; }; then
|
||||
found=$(find "$tmp" -type f -name "$bin" ! -name "*.*" | head -1)
|
||||
if [[ -n "$found" ]]; then
|
||||
cp "$found" "$HOME/.local/bin/$bin"
|
||||
chmod +x "$HOME/.local/bin/$bin"
|
||||
rm -rf "$tmp"; return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
echo " FAIL could not download/extract $bin from $repo"; return 1
|
||||
}
|
||||
|
||||
touch ~/.hushlogin
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mkdir -p "$ZSH_CONFIG"
|
||||
path+=("$HOME/.local/bin")
|
||||
|
||||
for p in git starship fzf eza rsync lazydocker fastfetch tmux; do
|
||||
command -v $p >/dev/null && continue
|
||||
# Package availability checks can hang on slow/unreachable mirrors (cold dnf
|
||||
# metadata, apt update, pacman -Sy). Wrap them in a 20s timeout so the bootstrap
|
||||
# never stalls the shell; a timed-out check just falls through to the next PM
|
||||
# or to the binary installers below. Verbose output so real builds (cargo) and
|
||||
# slow installs are visible as progress instead of an apparent hang.
|
||||
for p in git starship fzf eza rsync lazydocker fastfetch tmux bat zoxide; do
|
||||
if command -v $p >/dev/null; then
|
||||
echo " ok $p (already installed)"
|
||||
continue
|
||||
fi
|
||||
echo " ... $p (checking package managers)"
|
||||
if command -v brew >/dev/null; then
|
||||
echo "Installing $p using brew" && brew install $p && continue
|
||||
echo " brew installing $p" && brew install $p && continue
|
||||
fi
|
||||
if command -v apt >/dev/null && apt-cache show $p >/dev/null 2>&1; then
|
||||
echo "Installing $p using Apt" && sudo apt install $p -y && continue
|
||||
if command -v apt >/dev/null; then
|
||||
if timeout 20 apt-cache show $p >/dev/null 2>&1; then
|
||||
echo " apt installing $p" && sudo apt install -y $p && continue
|
||||
else
|
||||
echo " apt $p not available (or metadata check timed out)"
|
||||
fi
|
||||
fi
|
||||
if command -v dnf >/dev/null && dnf list available -y $p >/dev/null 2>&1; then
|
||||
echo "Installing $p using dnf" && sudo dnf install $p -y && continue
|
||||
if command -v dnf >/dev/null; then
|
||||
if timeout 20 dnf list available $p >/dev/null 2>&1; then
|
||||
echo " dnf installing $p" && sudo dnf install -y $p && continue
|
||||
else
|
||||
echo " dnf $p not available (or metadata check timed out)"
|
||||
fi
|
||||
fi
|
||||
if command -v pacman >/dev/null && pacman -Si $p >/dev/null 2>&1; then
|
||||
echo "Installing $p using pacman" && sudo pacman -Sy --noconfirm $p && continue
|
||||
if command -v pacman >/dev/null; then
|
||||
if timeout 20 pacman -Si $p >/dev/null 2>&1; then
|
||||
echo " pacman installing $p" && sudo pacman -Sy --noconfirm $p && continue
|
||||
else
|
||||
echo " pacman $p not available (or metadata check timed out)"
|
||||
fi
|
||||
fi
|
||||
if command -v cargo >/dev/null; then
|
||||
echo "Installing $p using cargo" && cargo install $p --locked && continue
|
||||
echo " cargo installing $p (may take a few minutes to compile)" && cargo install $p --locked && continue
|
||||
fi
|
||||
echo " FAIL could not install $p by any method; skipping"
|
||||
done
|
||||
|
||||
if ! command -v starship >/dev/null; then
|
||||
echo "Installing starship binary..." && \
|
||||
curl -sSL https://starship.rs/install.sh | sh -s -- -b ~/.local/bin -y >/dev/null 2>&1
|
||||
echo " curl installing starship binary"
|
||||
curl -fsSL --connect-timeout 5 --max-time 30 https://starship.rs/install.sh | sh -s -- -b ~/.local/bin -y
|
||||
fi
|
||||
|
||||
if ! command -v lazydocker >/dev/null; then
|
||||
echo "Installing lazydocker binary..." && \
|
||||
curl -sSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | \
|
||||
DIR=$HOME/.local/bin bash >/dev/null 2>&1
|
||||
echo " curl installing lazydocker binary"
|
||||
curl -fsSL --connect-timeout 5 --max-time 30 https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | \
|
||||
DIR=$HOME/.local/bin bash
|
||||
fi
|
||||
|
||||
# Debian/Ubuntu ships bat as 'batcat' and fd as 'fdfind'; symlink to the
|
||||
# canonical names so the zshrc integrations (which call bat/fd) work everywhere.
|
||||
if ! command -v bat >/dev/null && command -v batcat >/dev/null; then
|
||||
ln -sf "$(command -v batcat)" "$HOME/.local/bin/bat"
|
||||
fi
|
||||
if ! command -v fd >/dev/null && command -v fdfind >/dev/null; then
|
||||
ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd"
|
||||
fi
|
||||
|
||||
# git-delta: the package is named 'git-delta' on every package manager but the
|
||||
# binary is 'delta' (name mismatch vs the install loop above), so install via the
|
||||
# same PM cascade using the real package name. Used by zshrc as GIT_PAGER.
|
||||
if ! command -v delta >/dev/null; then
|
||||
echo " ... delta (checking package managers, package name is 'git-delta')"
|
||||
if command -v brew >/dev/null; then
|
||||
echo " brew installing git-delta" && brew install git-delta
|
||||
elif command -v apt >/dev/null && timeout 20 apt-cache show git-delta >/dev/null 2>&1; then
|
||||
echo " apt installing git-delta" && sudo apt install -y git-delta
|
||||
elif command -v dnf >/dev/null && timeout 20 dnf list available git-delta >/dev/null 2>&1; then
|
||||
echo " dnf installing git-delta" && sudo dnf install -y git-delta
|
||||
elif command -v pacman >/dev/null && timeout 20 pacman -Si git-delta >/dev/null 2>&1; then
|
||||
echo " pacman installing git-delta" && sudo pacman -Sy --noconfirm git-delta
|
||||
elif command -v cargo >/dev/null; then
|
||||
echo " cargo installing git-delta (may take a few minutes to compile)" && cargo install git-delta --locked
|
||||
else
|
||||
echo " FAIL could not install delta; skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
arch=$(uname -m)
|
||||
target_musl=""; target_gnu=""
|
||||
case "$arch" in
|
||||
x86_64) target_fast="linux-amd64" && target_eza="x86_64-unknown-linux-gnu" ;;
|
||||
aarch64|arm64) target_fast="linux-aarch64" && target_eza="aarch64-unknown-linux-gnu" ;;
|
||||
armv7l) target_fast="linux-armv7l" && target_eza="armv7-unknown-linux-gnueabihf" ;;
|
||||
x86_64) target_fast="linux-amd64" && target_eza="x86_64-unknown-linux-gnu" \
|
||||
&& target_musl="x86_64-unknown-linux-musl" && target_gnu="x86_64-unknown-linux-gnu" ;;
|
||||
aarch64|arm64) target_fast="linux-aarch64" && target_eza="aarch64-unknown-linux-gnu" \
|
||||
&& target_musl="aarch64-unknown-linux-musl" && target_gnu="aarch64-unknown-linux-gnu" ;;
|
||||
armv7l) target_fast="linux-armv7l" && target_eza="armv7-unknown-linux-gnueabihf" \
|
||||
&& target_musl="armv7-unknown-linux-musleabihf" && target_gnu="armv7-unknown-linux-gnueabihf" ;;
|
||||
esac
|
||||
|
||||
if [[ -n $target_fast && -n $target_eza ]]; then
|
||||
if ! command -v fastfetch >/dev/null; then
|
||||
curl -sSL -o /tmp/fastfetch.tar.gz "https://github.com/fastfetch-cli/fastfetch/releases/latest/download/fastfetch-${target_fast}.tar.gz"
|
||||
tar -xzf /tmp/fastfetch.tar.gz -C /tmp
|
||||
ffdir=$(find /tmp -maxdepth 1 -type d -name 'fastfetch*' | head -1)
|
||||
cp "$ffdir/usr/bin/fastfetch" "$HOME/.local/bin/"
|
||||
chmod +x "$HOME/.local/bin/fastfetch"
|
||||
mkdir -p "$HOME/.local/share/fastfetch"
|
||||
cp -r "$ffdir/usr/share/fastfetch/"* "$HOME/.local/share/fastfetch/"
|
||||
rm -rf "$ffdir" /tmp/fastfetch.tar.gz
|
||||
echo " curl installing fastfetch (github release tarball)"
|
||||
if curl -fsSL --connect-timeout 5 --max-time 30 -o /tmp/fastfetch.tar.gz \
|
||||
"https://github.com/fastfetch-cli/fastfetch/releases/latest/download/fastfetch-${target_fast}.tar.gz" \
|
||||
&& tar -xzf /tmp/fastfetch.tar.gz -C /tmp 2>/dev/null; then
|
||||
ffdir=$(find /tmp -maxdepth 1 -type d -name 'fastfetch*' | head -1)
|
||||
if [[ -n "$ffdir" ]]; then
|
||||
cp "$ffdir/usr/bin/fastfetch" "$HOME/.local/bin/"
|
||||
chmod +x "$HOME/.local/bin/fastfetch"
|
||||
mkdir -p "$HOME/.local/share/fastfetch"
|
||||
cp -r "$ffdir/usr/share/fastfetch/"* "$HOME/.local/share/fastfetch/"
|
||||
fi
|
||||
rm -rf "$ffdir" /tmp/fastfetch.tar.gz
|
||||
else
|
||||
rm -f /tmp/fastfetch.tar.gz
|
||||
echo " FAIL could not download/extract fastfetch; skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v eza >/dev/null; then
|
||||
curl -sSL -o /tmp/eza.tar.gz "https://github.com/eza-community/eza/releases/latest/download/eza_${target_eza}.tar.gz"
|
||||
tar -xzf /tmp/eza.tar.gz -C "$HOME/.local/bin/"
|
||||
chmod +x "$HOME/.local/bin/eza"
|
||||
echo " curl installing eza (github release tarball)"
|
||||
if curl -fsSL --connect-timeout 5 --max-time 30 -o /tmp/eza.tar.gz \
|
||||
"https://github.com/eza-community/eza/releases/latest/download/eza_${target_eza}.tar.gz" \
|
||||
&& tar -xzf /tmp/eza.tar.gz -C "$HOME/.local/bin/" 2>/dev/null; then
|
||||
chmod +x "$HOME/.local/bin/eza"
|
||||
else
|
||||
rm -f /tmp/eza.tar.gz
|
||||
echo " FAIL could not download/extract eza; skipping"
|
||||
fi
|
||||
rm -f /tmp/eza.tar.gz
|
||||
fi
|
||||
fi
|
||||
|
||||
# GitHub-release binary fallbacks for tools often missing from distro repos
|
||||
# (e.g. bat/zoxide/git-delta on RHEL/Alma without EPEL, or boxes with no cargo).
|
||||
# Tried after the PM/cargo loop above already failed for these. Static musl
|
||||
# builds preferred so there are no glibc dependencies; delta ships gnu-only.
|
||||
if ! command -v bat >/dev/null && [[ -n "$target_musl" ]]; then
|
||||
_gh_install_bin "sharkdp/bat" "bat-v%v-$target_musl.tar.gz" "bat"
|
||||
fi
|
||||
|
||||
if ! command -v zoxide >/dev/null && [[ -n "$target_musl" ]]; then
|
||||
_gh_install_bin "ajeetdsouza/zoxide" "zoxide-%v-$target_musl.tar.gz" "zoxide"
|
||||
fi
|
||||
|
||||
if ! command -v delta >/dev/null && [[ -n "$target_gnu" ]]; then
|
||||
_gh_install_bin "dandavison/delta" "delta-%v-$target_gnu.tar.gz" "delta"
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.config/zsh"
|
||||
|
||||
[ ! -d $OHMYZSH ] && git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git $OHMYZSH
|
||||
if [ ! -d "$OHMYZSH" ]; then
|
||||
echo " git cloning oh-my-zsh"
|
||||
GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \
|
||||
clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git $OHMYZSH || \
|
||||
echo " FAIL could not clone oh-my-zsh"
|
||||
fi
|
||||
|
||||
for plug in \
|
||||
"zsh-autosuggestions|https://github.com/zsh-users/zsh-autosuggestions" \
|
||||
@@ -76,7 +243,11 @@ do
|
||||
name="${plug%%|*}"
|
||||
url="${plug##*|}"
|
||||
dir="$OHMYZSH_CUSTOM/plugins/$name"
|
||||
[[ ! -d "$dir" ]] && git clone "$url" "$dir" 2>/dev/null
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
echo " git cloning $name"
|
||||
GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \
|
||||
clone "$url" "$dir" 2>/dev/null || echo " FAIL could not clone $name"
|
||||
fi
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
|
||||
Reference in New Issue
Block a user