diff --git a/.agents/skills/add_installer/SKILL.md b/.agents/skills/add_installer/SKILL.md index 9a3addf..bcd55ee 100644 --- a/.agents/skills/add_installer/SKILL.md +++ b/.agents/skills/add_installer/SKILL.md @@ -113,7 +113,9 @@ install_() { # Use pkg_install for distro packages (it automatically handles rollback hooks!): # pkg_install "arch:|debian:|fedora:" - # Or manual downloads: + # Or manual downloads (always use download_file for resumability!): + # local url="https://..." + # download_file "$url" "$TMP_DIR/binary" # cp "$TMP_DIR/binary" "$HOME/.local/bin/binary" # track_file "$HOME/.local/bin/binary" # Important for rollback! } @@ -157,6 +159,7 @@ These are pre-loaded by `bootstrap.sh` — no need to source them manually in in | `confirm "prompt"` | Interactive yes/no prompt, returns 0 for yes | | `has_command ` | Check if a command exists (returns 0/1) | | `make_temp_dir` | Create and echo a temp directory path | +| `download_file ` | Resumable and cached download of `` to ``. Uses `~/.local/state/bootstrap/cache/`. | ### From `lib/platform.sh` @@ -216,6 +219,21 @@ if [ -z "$latest_tag" ]; then fi ``` +### Resumable Download and Extraction + +```bash +local url="https://github.com/owner/repo/releases/download/${version}/archive.tar.gz" +local dest="$TMP_DIR/archive.tar.gz" + +# Resumable, cached download +download_file "$url" "$dest" + +# Extract and install +tar -xzf "$dest" -C "$TMP_DIR" +sudo cp "$TMP_DIR/binary" /usr/local/bin/binary +track_file "/usr/local/bin/binary" +``` + --- ## Rules & Conventions diff --git a/docs/rollback_design.md b/docs/rollback_design.md index 0a27d99..8a7652e 100644 --- a/docs/rollback_design.md +++ b/docs/rollback_design.md @@ -105,3 +105,29 @@ Because `b ware ` allows users to modify installation scripts: The `pkg_remove` helper utilizes reference counting via simple text files (e.g., `~/.local/state/bootstrap/packages/curl`). - **On `pkg_install`**: Append tool name. - **On `pkg_remove`**: Remove tool name. If empty, proceed with system uninstallation. + +## 8. Fault Tolerance, Resumability, and Interrupted Installations + +To handle failures during installation (e.g., network drops, script errors, or user cancellation via `Ctrl+C`), the CLI incorporates a transactional approach that balances **automatic rollback** and **resumability**: + +### A. The Interruption Trap & Prompt +When running an installer, the central router (`lib/routes.sh`) traps `SIGINT` and `SIGTERM` signals. If the installation fails or is interrupted: +1. The trap catches the event and stops execution. +2. The user is prompted interactively: + - **Rollback (r)**: Invokes `execute_rollback ` immediately to clean up all partial modifications. + - **Keep (k)**: Preserves the partial changes and leaves the `.cmds` manifest intact. +3. In non-interactive environments (e.g., CI/CD or scripts), the CLI defaults to **automatic rollback** to keep the system clean. + +### B. Resuming via Preserved Manifests +If the user chooses to **keep** the partial state and runs `b ` again: +1. `setup_uninstaller_context` detects that a manifest already exists and that the tool was *not* successfully installed (no `INSTALL: ` in the history log). +2. It **preserves** the existing manifest instead of wiping it. +3. As the script runs again from the top, new rollback commands are prepended to the existing manifest, maintaining the correct LIFO order without losing the tracking of previously completed steps. + +### C. Resumable Downloads (Caching Layer) +To make rerunning an interrupted script fast and efficient, installers use `download_file ` instead of raw `curl`: +1. It downloads the payload to a central cache directory: `~/.local/state/bootstrap/cache/`. +2. It uses `curl -C -` to continue the download from the byte offset where it was interrupted. +3. Once completed, it copies the cached file to the installer's temp directory. +4. Distro package manager commands (`pkg_install`) and shell snippets (`write_env_snippet`) are naturally idempotent, allowing the script to breeze through already completed steps in milliseconds and resume exactly where the heavy work failed. + diff --git a/installers/install_agy.sh b/installers/install_agy.sh index 1ba2483..4ee8aab 100644 --- a/installers/install_agy.sh +++ b/installers/install_agy.sh @@ -96,7 +96,7 @@ install_agy() { fi log_info "Downloading release package..." - curl -fsSL "$url" -o "$staging_payload" + download_file "$url" "$staging_payload" # Verify SHA512 Checksum local actual_hash diff --git a/installers/install_asciicinema.sh b/installers/install_asciicinema.sh index e0448c6..1346bb8 100644 --- a/installers/install_asciicinema.sh +++ b/installers/install_asciicinema.sh @@ -76,7 +76,7 @@ install_asciicinema() { local download_url="https://github.com/asciinema/asciinema/releases/download/${latest_tag}/asciinema-${asciinema_arch}" log_info "Downloading asciinema ${latest_tag} for ${arch}..." - curl -fsSL "$download_url" -o "$TMP_DIR/asciinema" + download_file "$download_url" "$TMP_DIR/asciinema" log_info "Installing asciinema to /usr/local/bin..." sudo cp "$TMP_DIR/asciinema" /usr/local/bin/asciinema diff --git a/installers/install_bat.sh b/installers/install_bat.sh index 0cb14a3..838c944 100644 --- a/installers/install_bat.sh +++ b/installers/install_bat.sh @@ -63,7 +63,7 @@ install_bat() { local deb_url="https://github.com/sharkdp/bat/releases/download/${latest_tag}/bat_${version}_${deb_arch}.deb" log_info "Downloading Bat from ${deb_url}..." - curl -fsSL "$deb_url" -o "$TMP_DIR/bat.deb" + download_file "$deb_url" "$TMP_DIR/bat.deb" log_info "Installing Bat package..." sudo apt install -y "$TMP_DIR/bat.deb" diff --git a/installers/install_node.sh b/installers/install_node.sh index 0ec7705..201a9bc 100644 --- a/installers/install_node.sh +++ b/installers/install_node.sh @@ -45,8 +45,7 @@ install_nvm() { local nvm_url="https://github.com/nvm-sh/nvm/archive/refs/tags/${latest_tag}.tar.gz" log_info "Downloading NVM from $nvm_url..." - - curl -fsSL "$nvm_url" -o "$TMP_DIR/nvm.tar.gz" + download_file "$nvm_url" "$TMP_DIR/nvm.tar.gz" log_info "Extracting NVM archive directly to $HOME/.nvm (stripping versioned subfolder to keep config generic)..." mkdir -p "$HOME/.nvm" diff --git a/installers/install_nvim.sh b/installers/install_nvim.sh index 5dd080a..c5e4c08 100644 --- a/installers/install_nvim.sh +++ b/installers/install_nvim.sh @@ -79,7 +79,7 @@ install_nvim() { local nvim_url="https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-${nvim_arch}.tar.gz" log_info "Downloading Neovim v${NVIM_VERSION} for ${arch}..." - curl -fsSL "$nvim_url" -o "$TMP_DIR/nvim.tar.gz" + download_file "$nvim_url" "$TMP_DIR/nvim.tar.gz" tar -xzf "$TMP_DIR/nvim.tar.gz" -C "$TMP_DIR" diff --git a/installers/install_pnpm.sh b/installers/install_pnpm.sh index dc5220d..046d228 100644 --- a/installers/install_pnpm.sh +++ b/installers/install_pnpm.sh @@ -34,7 +34,11 @@ trap cleanup EXIT # ─── Helper Functions ───────────────────────────────────────────────── download() { + if [ -n "${2:-}" ]; then + download_file "$1" "$2" + else curl -fsSL "$1" + fi } is_glibc_compatible() { @@ -147,7 +151,7 @@ install_pnpm() { if [ "$major_version" -ge 11 ]; then # v11+: distributed as tarballs containing the binary and dist/ directory - download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}.tar.gz" > "$TMP_DIR/pnpm.tar.gz" || { + download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}.tar.gz" "$TMP_DIR/pnpm.tar.gz" || { log_error "Failed to download pnpm tarball." return 1 } @@ -162,7 +166,7 @@ install_pnpm() { } else # Older versions: distributed as a single executable binary - download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}" > "$TMP_DIR/pnpm" || { + download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}" "$TMP_DIR/pnpm" || { log_error "Failed to download pnpm binary." return 1 } diff --git a/installers/install_rust.sh b/installers/install_rust.sh index 5b0d74b..e6db38f 100644 --- a/installers/install_rust.sh +++ b/installers/install_rust.sh @@ -76,7 +76,7 @@ install_rust() { local dest="$TMP_DIR/rustup-init" log_info "Downloading rustup-init..." - curl -fsSL "$url" -o "$dest" + download_file "$url" "$dest" chmod +x "$dest" diff --git a/installers/install_starship.sh b/installers/install_starship.sh index 0377684..da2d144 100644 --- a/installers/install_starship.sh +++ b/installers/install_starship.sh @@ -59,7 +59,7 @@ install_starship() { log_info "Downloading Starship from ${download_url}..." local archive="$TMP_DIR/starship.tar.gz" - curl -fsSL "$download_url" -o "$archive" + download_file "$download_url" "$archive" # Extract the binary log_info "Extracting Starship binary..." diff --git a/installers/install_uv.sh b/installers/install_uv.sh index e76c48b..64ca56a 100644 --- a/installers/install_uv.sh +++ b/installers/install_uv.sh @@ -68,7 +68,7 @@ install_uv() { log_info "Downloading uv from ${download_url}..." local archive="$TMP_DIR/uv.tar.gz" - curl -fsSL "$download_url" -o "$archive" + download_file "$download_url" "$archive" # Extract the binaries log_info "Extracting uv binaries..." diff --git a/installers/install_yazi.sh b/installers/install_yazi.sh index 9bebe0a..5d6fc9d 100755 --- a/installers/install_yazi.sh +++ b/installers/install_yazi.sh @@ -76,7 +76,7 @@ install_yazi() { local deb_url="https://github.com/sxyazi/yazi/releases/download/${latest_tag}/yazi-x86_64-unknown-linux-gnu.deb" log_info "Downloading Yazi ${latest_tag} from ${deb_url}..." - curl -fsSL "$deb_url" -o "$TMP_DIR/yazi.deb" + download_file "$deb_url" "$TMP_DIR/yazi.deb" log_info "Installing Yazi package..." sudo apt install -y "$TMP_DIR/yazi.deb" diff --git a/lib/common.sh b/lib/common.sh index 4668ba1..27e76c8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -84,9 +84,46 @@ version_lt() { done return 1 } +# Cached and resumable download helper +download_file() { + local url="$1" + local dest="$2" + local cache_dir="$HOME/.local/state/bootstrap/cache" + + mkdir -p "$cache_dir" + + local safe_name + if has_command md5sum; then + safe_name=$(echo -n "$url" | md5sum | cut -d' ' -f1) + elif has_command shasum; then + safe_name=$(echo -n "$url" | shasum | cut -d' ' -f1) + else + safe_name=$(echo -n "$url" | tr -c '[:alnum:]_.-' '_') + fi + + local base_name + base_name=$(basename "$url") + local cache_file="$cache_dir/${safe_name}_${base_name}" + + log_info "Downloading $base_name (resumable)..." + if ! curl -fL -C - "$url" -o "$cache_file"; then + local exit_code=$? + # Exit code 33: HTTP server doesn't support ranges/resuming + # Exit code 36: Bad download resume offset + if [ $exit_code -eq 33 ] || [ $exit_code -eq 36 ]; then + log_warn "Server does not support resuming. Retrying from scratch..." + rm -f "$cache_file" + curl -fL "$url" -o "$cache_file" || return 1 + else + return $exit_code + fi + fi + + mkdir -p "$(dirname "$dest")" + cp "$cache_file" "$dest" +} # Export functions and variables for subshells export _LIB_COMMON_SOURCED=1 export RED GREEN YELLOW BLUE NC -export -f require_bash log_info log_success log_warn log_error confirm has_command make_temp_dir version_lt - +export -f require_bash log_info log_success log_warn log_error confirm has_command make_temp_dir version_lt download_file diff --git a/lib/rollback.sh b/lib/rollback.sh index 152cb4f..8bd8cd6 100644 --- a/lib/rollback.sh +++ b/lib/rollback.sh @@ -20,8 +20,16 @@ setup_uninstaller_context() { local tool="$1" export BOOTSTRAP_CURRENT_TOOL="$tool" export BOOTSTRAP_UNINSTALLER_CMDS="$BOOTSTRAP_UNINSTALLERS_DIR/${tool}.cmds" - # Ensure fresh manifest for this run - rm -f "$BOOTSTRAP_UNINSTALLER_CMDS" + + # If a manifest already exists and the tool is NOT marked as successfully installed + # in history.log, we treat this as a resumed run. We preserve the manifest so + # that new commands are prepended to the existing ones. + if [ -f "$BOOTSTRAP_UNINSTALLER_CMDS" ] && ! grep -q "^INSTALL: $tool$" "$BOOTSTRAP_HISTORY_LOG" 2>/dev/null; then + log_info "Resuming installation of '$tool'. Preserving existing rollback manifest." + else + # Fresh installation or reinstall, start with a clean slate + rm -f "$BOOTSTRAP_UNINSTALLER_CMDS" + fi touch "$BOOTSTRAP_UNINSTALLER_CMDS" } diff --git a/lib/routes.sh b/lib/routes.sh index 0018599..5993e23 100755 --- a/lib/routes.sh +++ b/lib/routes.sh @@ -114,11 +114,54 @@ run_ware() { # Run the script (edited or unchanged) log_info "Running ${display_name} installer..." setup_uninstaller_context "$tool" + + # Set trap for signals to intercept interruption and allow user to choose rollback/keep + local interrupted=false + trap 'interrupted=true' INT TERM + bash "$temp_script" "${cmd_args[@]}" local run_status=$? - if [ "$run_status" -eq 0 ]; then + # Restore default traps + trap - INT TERM + + if [ "$run_status" -eq 0 ] && [ "$interrupted" = "false" ]; then mark_install_success "$tool" + else + echo + if [ "$interrupted" = "true" ]; then + log_error "Installation of ${display_name} was interrupted." + run_status=130 + else + log_error "Installation of ${display_name} failed with exit code $run_status." + fi + + local choice="" + if [ -t 0 ]; then + while true; do + read -r -p "Would you like to [r]ollback partial changes, or [k]eep them to resume/debug later? (r/k): " choice