diff --git a/.agents/skills/add_installer/SKILL.md b/.agents/skills/add_installer/SKILL.md index b7c2cc0..3a65d7f 100644 --- a/.agents/skills/add_installer/SKILL.md +++ b/.agents/skills/add_installer/SKILL.md @@ -59,7 +59,7 @@ The central router `lib/routes.sh` and autocomplete function in `b.sh` will dyna ### Step 3: Implement Rollback Tracking (Crucial) To ensure the user can seamlessly use `b rb `, all manual modifications must be tracked: -- When extracting binaries to `~/.local/bin/`, use `track_file "$HOME/.local/bin/binary"`. +- When extracting binaries to `~/.local/bin/`, use `track_file "${BOOTSTRAP_BIN:-$HOME/.local/share/bootstrap/bin}/binary"`. - When creating directories like `~/.config/tool/`, use `track_dir "$HOME/.config/tool"`. - When running manual apt/dnf/npm commands, log their inverses: `add_rollback_cmd "sudo npm uninstall -g package"`. Note: `pkg_install`, `write_env_snippet`, and `write_alias_snippet` will automatically track themselves. @@ -116,8 +116,8 @@ install_() { # 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! + # cp "$TMP_DIR/binary" "${BOOTSTRAP_BIN:-$HOME/.local/share/bootstrap/bin}/binary" + # track_file "${BOOTSTRAP_BIN:-$HOME/.local/share/bootstrap/bin}/binary" # Important for rollback! } # ─── Shell Configuration (if needed) ───────────────────────────────── @@ -200,14 +200,13 @@ trap cleanup EXIT ### Distro-specific mapping ```bash -pkg_install "arch:neovim|debian:nvim|fedora:neovim" "curl" "git" +pkg_install "arch:neovim|debian:nvim|fedora:neovim" "git" ``` ### Fetching latest GitHub release tag ```bash local latest_tag="" -if has_command curl; then latest_tag=$(curl -sL https://api.github.com/repos///releases/latest \ | grep '"tag_name":' | head -n1 \ | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) diff --git a/bootstrap.sh b/bootstrap.sh index 8aac6c8..53ab262 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -8,11 +8,6 @@ if [ -z "${BASH_VERSION:-}" ]; then exit 1 fi -if ! command -v curl >/dev/null 2>&1; then - echo "Error: curl is required to run this script." >&2 - exit 1 -fi - # Detect if the script is sourced is_sourced=false if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then @@ -41,7 +36,7 @@ else BOOTSTRAP_SOURCE_DIR="$BOOTSTRAP_TMP_DIR" _BASE_URL="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master" - _LIBS=("lib/common.sh" "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh" "lib/json.sh" "lib/plugins.sh") + _LIBS=("lib/common.sh" "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh" "lib/plugins.sh" "lib/registry_helpers.sh" "lib/github.sh") _curl_args=() for _lib in "${_LIBS[@]}"; do @@ -56,6 +51,8 @@ if [ -f "$BOOTSTRAP_SOURCE_DIR/lib/common.sh" ]; then . "$BOOTSTRAP_SOURCE_DIR/lib/rollback.sh" . "$BOOTSTRAP_SOURCE_DIR/lib/platform.sh" . "$BOOTSTRAP_SOURCE_DIR/lib/shell_config.sh" + . "$BOOTSTRAP_SOURCE_DIR/lib/registry_helpers.sh" + . "$BOOTSTRAP_SOURCE_DIR/lib/github.sh" init_rollback_system else echo "Error: Failed to locate or download bootstrap libraries." >&2 @@ -68,10 +65,27 @@ install_bootstrap() { [ -f "$HOME/.bashrc" ] && target_files+=("$HOME/.bashrc") local routes_dir="$HOME/.config/bootstrap" - mkdir -p "$routes_dir" mkdir -p "$routes_dir/env.d" mkdir -p "$routes_dir/aliases.d" + # Initialize XDG directories + mkdir -p "$HOME/.local/share/bootstrap/bin" + mkdir -p "$HOME/.local/share/bootstrap/opt" + mkdir -p "$HOME/.local/share/bootstrap/runtimes" + mkdir -p "$HOME/.local/state/bootstrap/logs" + mkdir -p "$HOME/.local/state/bootstrap/rollback" + mkdir -p "$HOME/.cache/bootstrap/downloads" + mkdir -p "$HOME/.cache/bootstrap/tmp" + + # Create the universal binary PATH snippet + cat << 'EOF' > "$routes_dir/env.d/bootstrap-bin.sh" +export BOOTSTRAP_BIN="$BOOTSTRAP_BIN" +case ":$PATH:" in + *":$BOOTSTRAP_BIN:"*) ;; + *) export PATH="$BOOTSTRAP_BIN:$PATH" ;; +esac +EOF + # List of all files to download/copy local files=( "VERSION" @@ -82,7 +96,8 @@ install_bootstrap() { "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh" - "lib/json.sh" + "lib/registry_helpers.sh" + "lib/github.sh" "lib/plugins.sh" "commands/help.sh" "commands/con.sh" @@ -90,6 +105,11 @@ install_bootstrap() { "commands/up.sh" ) + if ! pkg_check jq >/dev/null 2>&1; then + log_info "jq is missing. Installing jq..." + pkg_install jq + fi + if [ -f "$_SCRIPT_DIR/b.sh" ] && [ -f "$_SCRIPT_DIR/lib/routes.sh" ]; then log_info "Using local files from repository..." for file in "${files[@]}"; do @@ -139,6 +159,13 @@ install_bootstrap() { # >>> bootstrap-cli setup >>> export BOOTSTRAP_DIR="$HOME/.config/bootstrap" +export BOOTSTRAP_DATA_DIR="$HOME/.local/share/bootstrap" +export BOOTSTRAP_STATE_DIR="$HOME/.local/state/bootstrap" +export BOOTSTRAP_CACHE_DIR="$HOME/.cache/bootstrap" +export BOOTSTRAP_BIN="$BOOTSTRAP_DATA_DIR/bin" +export BOOTSTRAP_OPT="$BOOTSTRAP_DATA_DIR/opt" +export BOOTSTRAP_RUNTIMES="$BOOTSTRAP_DATA_DIR/runtimes" + [ -f "$BOOTSTRAP_DIR/b.sh" ] && . "$BOOTSTRAP_DIR/b.sh" for f in "$BOOTSTRAP_DIR/env.d/"*.sh; do [ -r "$f" ] && . "$f"; done for f in "$BOOTSTRAP_DIR/aliases.d/"*.sh; do [ -r "$f" ] && . "$f"; done diff --git a/commands/uninstall.sh b/commands/uninstall.sh index 8dde304..a4934aa 100644 --- a/commands/uninstall.sh +++ b/commands/uninstall.sh @@ -78,6 +78,9 @@ EOF fi # Remove the installation directory +rm -rf "$BOOTSTRAP_DATA_DIR" +rm -rf "$BOOTSTRAP_STATE_DIR" +rm -rf "$BOOTSTRAP_CACHE_DIR" rm -rf "${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}" if [ "$FORCE" = "true" ]; then diff --git a/installers/install_agy.sh b/installers/install_agy.sh index 4ee8aab..793a086 100644 --- a/installers/install_agy.sh +++ b/installers/install_agy.sh @@ -2,21 +2,16 @@ # Tool: agy # DisplayName: Antigravity # Description: Install Antigravity CLI +# Strategy: binary # # Antigravity CLI Installer Script (Linux Only) # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail # Constants DOWNLOAD_BASE_URL="https://antigravity-cli-auto-updater-974169037036.us-central1.run.app" -TARGET_DIR="$HOME/.local/bin" +TARGET_DIR="$BOOTSTRAP_BIN" BINARY_PATH="$TARGET_DIR/agy" install_agy() { @@ -55,19 +50,12 @@ install_agy() { exit 1 fi - # POSIX-compliant JSON parser (no jq dependencies) - parse_json_key() { - local payload="$1" - local key="$2" - echo "$payload" | sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' - } - local version local url local sha512 - version=$(parse_json_key "$manifest_json" "version") - url=$(parse_json_key "$manifest_json" "url") - sha512=$(parse_json_key "$manifest_json" "sha512") + version=$(echo "$manifest_json" | jq -r '.version // empty') + url=$(echo "$manifest_json" | jq -r '.url // empty') + sha512=$(echo "$manifest_json" | jq -r '.sha512 // empty') if [ -z "$url" ] || [ -z "$sha512" ]; then log_error "Failed to parse release manifest." @@ -134,16 +122,11 @@ install_agy() { track_file "$BINARY_PATH" log_success "Antigravity CLI successfully installed to $BINARY_PATH." + register_tool "agy" "binary" "" "github:sortedcord/agy" } configure_shell() { - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "local-bin path" - done - write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"' } run_handoff() { diff --git a/installers/install_asciicinema.sh b/installers/install_asciicinema.sh index 1346bb8..f9e1dd4 100644 --- a/installers/install_asciicinema.sh +++ b/installers/install_asciicinema.sh @@ -2,16 +2,11 @@ # Tool: asciicinema # DisplayName: asciicinema # Description: Install asciinema terminal recorder +# Strategy: binary # # asciinema Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -21,12 +16,9 @@ cleanup() { trap cleanup EXIT install_asciicinema() { - local latest_tag="" if has_command curl; then log_info "Fetching latest asciinema version from GitHub..." - latest_tag=$(curl -sL https://api.github.com/repos/asciinema/asciinema/releases/latest \ - | grep '"tag_name":' | head -n1 \ - | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) + latest_tag=$(github_get_latest_release "asciinema/asciinema") fi if [ -z "$latest_tag" ]; then @@ -73,22 +65,21 @@ install_asciicinema() { *) log_error "Unsupported architecture: $arch"; exit 1 ;; esac - local download_url="https://github.com/asciinema/asciinema/releases/download/${latest_tag}/asciinema-${asciinema_arch}" - log_info "Downloading asciinema ${latest_tag} for ${arch}..." - download_file "$download_url" "$TMP_DIR/asciinema" + github_download_asset "asciinema/asciinema" "$latest_tag" "asciinema-${asciinema_arch}" "$TMP_DIR/asciinema" - log_info "Installing asciinema to /usr/local/bin..." - sudo cp "$TMP_DIR/asciinema" /usr/local/bin/asciinema - sudo chmod +x /usr/local/bin/asciinema - track_file "/usr/local/bin/asciinema" + log_info "Installing asciinema to $BOOTSTRAP_BIN..." + cp "$TMP_DIR/asciinema" "$BOOTSTRAP_BIN/asciinema" + chmod +x "$BOOTSTRAP_BIN/asciinema" + track_file "$BOOTSTRAP_BIN/asciinema" # Create compatibility symlink matching the installer name spelling log_info "Creating compatibility symlink for asciicinema..." - sudo ln -sf /usr/local/bin/asciinema /usr/local/bin/asciicinema + ln -sf "$BOOTSTRAP_BIN/asciinema" /usr/local/bin/asciicinema track_file "/usr/local/bin/asciicinema" log_success "asciinema ${latest_tag} installed." + register_tool "asciicinema" "binary" "$latest_tag" "github:asciinema/asciinema" } main() { diff --git a/installers/install_bat.sh b/installers/install_bat.sh index 8fe6ccf..cf4a77a 100644 --- a/installers/install_bat.sh +++ b/installers/install_bat.sh @@ -2,16 +2,11 @@ # Tool: bat # DisplayName: Bat # Description: Install Bat (alternative to cat) and configure alias +# Strategy: binary # # Bat Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -21,69 +16,54 @@ cleanup() { trap cleanup EXIT install_bat() { - local distro - distro=$(detect_distro) - - if [ "$distro" = "arch" ]; then - log_info "Arch Linux detected" - log_info "Installing Bat..." - pkg_install bat - - elif [ "$distro" = "fedora" ]; then - log_info "Fedora detected" - log_info "Installing Bat..." - pkg_install bat - - elif [ "$distro" = "debian" ]; then - log_info "Debian/Ubuntu detected" - - pkg_install curl - - log_info "Fetching latest Bat version from GitHub..." - local latest_tag="" - latest_tag=$(curl -sL https://api.github.com/repos/sharkdp/bat/releases/latest | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) - - if [ -z "$latest_tag" ]; then - latest_tag="v0.26.1" - log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag" - else - log_info "Latest Bat version found: $latest_tag" + if has_command bat; then + if ! confirm "Bat is already installed. Reinstall/Upgrade?"; then + log_info "Skipping Bat installation." + return fi - - # Remove leading 'v' for file name version - local version="${latest_tag#v}" - - # Detect architecture mapping - local arch - arch=$(detect_arch) - local deb_arch="amd64" - if [ "$arch" = "arm64" ]; then - deb_arch="arm64" - fi - - local deb_url="https://github.com/sharkdp/bat/releases/download/${latest_tag}/bat_${version}_${deb_arch}.deb" - log_info "Downloading Bat from ${deb_url}..." - download_file "$deb_url" "$TMP_DIR/bat.deb" - - log_info "Installing Bat package..." - sudo apt install -y "$TMP_DIR/bat.deb" - add_rollback_cmd "sudo apt remove -y bat" - - else - log_error "Unsupported distribution." - exit 1 fi + + local arch + arch=$(detect_arch) + local target="" + case "$arch" in + x86_64) target="x86_64-unknown-linux-gnu" ;; + arm64) target="aarch64-unknown-linux-gnu" ;; + *) log_error "Unsupported architecture: $arch"; exit 1 ;; + esac + + log_info "Fetching latest Bat version from GitHub..." + local latest_tag="" + latest_tag=$(github_get_latest_release "sharkdp/bat") + + if [ -z "$latest_tag" ]; then + latest_tag="v0.26.1" + log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag" + else + log_info "Latest Bat version found: $latest_tag" + fi + + log_info "Downloading Bat ${latest_tag}..." + local archive="$TMP_DIR/bat.tar.gz" + github_download_asset "sharkdp/bat" "$latest_tag" "bat-${latest_tag}-${target}\.tar\.gz" "$archive" + + log_info "Extracting Bat binary..." + tar -xzf "$archive" -C "$TMP_DIR" + + local extract_dir="$TMP_DIR/bat-${latest_tag}-${target}" + + local target_dir="$BOOTSTRAP_BIN" + mkdir -p "$target_dir" + + log_info "Installing Bat to $target_dir/bat..." + cp "$extract_dir/bat" "$target_dir/bat" + chmod +x "$target_dir/bat" + track_file "$target_dir/bat" + + register_tool "bat" "binary" "$latest_tag" "github:sharkdp/bat" } configure_shell() { - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "bat alias" - done - if [ -f "$HOME/.bash_aliases" ]; then - remove_block "$HOME/.bash_aliases" "bat alias" - fi write_alias_snippet "bat" "alias cat='bat --paging=never -p'" } diff --git a/installers/install_docker.sh b/installers/install_docker.sh index af0e477..2b0e382 100644 --- a/installers/install_docker.sh +++ b/installers/install_docker.sh @@ -2,16 +2,11 @@ # Tool: docker # DisplayName: Docker # Description: Container runtime and orchestration platform +# Strategy: system # # Docker Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail # ─── Installation Logic ────────────────────────────────────────────── @@ -58,6 +53,7 @@ install_docker() { sudo systemctl enable --now docker.service || true sudo systemctl enable --now containerd.service || true fi + register_tool "docker" "system" "" "os-package-manager" } # ─── Main ───────────────────────────────────────────────────────────── diff --git a/installers/install_lazygit.sh b/installers/install_lazygit.sh index 4adbb88..ed52d77 100755 --- a/installers/install_lazygit.sh +++ b/installers/install_lazygit.sh @@ -2,16 +2,11 @@ # Tool: lazygit # DisplayName: lazygit # Description: Simple terminal UI for git commands +# Strategy: binary # # lazygit Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail # ─── Installation Logic ────────────────────────────────────────────── @@ -30,11 +25,7 @@ install_lazygit() { fi local latest_tag="" - if has_command curl; then - latest_tag=$(curl -sL https://api.github.com/repos/jesseduffield/lazygit/releases/latest \ - | grep '"tag_name":' | head -n1 \ - | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) - fi + latest_tag=$(github_get_latest_release "jesseduffield/lazygit") if [ -z "$latest_tag" ]; then latest_tag="v0.62.2" # fallback @@ -49,8 +40,6 @@ install_lazygit() { arch_str="arm64" fi - local url="https://github.com/jesseduffield/lazygit/releases/download/${latest_tag}/lazygit_${version}_linux_${arch_str}.tar.gz" - TMP_DIR="$(make_temp_dir)" cleanup() { rm -rf "$TMP_DIR"; } trap cleanup EXIT @@ -58,15 +47,16 @@ install_lazygit() { local dest="$TMP_DIR/lazygit.tar.gz" log_info "Downloading lazygit ${latest_tag}..." - download_file "$url" "$dest" + github_download_asset "jesseduffield/lazygit" "$latest_tag" "lazygit_${version}_linux_${arch_str}\.tar\.gz" "$dest" log_info "Extracting..." tar -xzf "$dest" -C "$TMP_DIR" - mkdir -p "$HOME/.local/bin" + mkdir -p "$BOOTSTRAP_BIN" cp "$TMP_DIR/lazygit" "$HOME/.local/bin/lazygit" chmod +x "$HOME/.local/bin/lazygit" track_file "$HOME/.local/bin/lazygit" + register_tool "lazygit" "binary" "$latest_tag" "github:jesseduffield/lazygit" } # ─── Main ───────────────────────────────────────────────────────────── diff --git a/installers/install_node.sh b/installers/install_node.sh index bfdce16..f730527 100644 --- a/installers/install_node.sh +++ b/installers/install_node.sh @@ -2,16 +2,11 @@ # Tool: node # DisplayName: Node # Description: Install Node.js (LTS) and NVM +# Strategy: managed # # Node.js and NVM Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -21,7 +16,7 @@ cleanup() { trap cleanup EXIT install_nvm() { - if has_command nvm || [ -s "$HOME/.nvm/nvm.sh" ]; then + if has_command nvm || [ -s "$BOOTSTRAP_RUNTIMES/nvm/nvm.sh" ]; then log_info "NVM is already installed." fi @@ -34,7 +29,7 @@ install_nvm() { # Try to fetch the latest version of NVM from GitHub API log_info "Fetching the latest NVM version..." local latest_tag="" - latest_tag=$(curl -sL https://api.github.com/repos/nvm-sh/nvm/releases/latest | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) + latest_tag=$(github_get_latest_release "nvm-sh/nvm") if [ -z "$latest_tag" ]; then latest_tag="v0.40.5" # Fallback version if API request fails @@ -47,25 +42,20 @@ install_nvm() { log_info "Downloading NVM from $nvm_url..." 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" - tar -xzf "$TMP_DIR/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + log_info "Extracting NVM archive directly to $BOOTSTRAP_RUNTIMES/nvm (stripping versioned subfolder to keep config generic)..." + mkdir -p "$BOOTSTRAP_RUNTIMES/nvm" + tar -xzf "$TMP_DIR/nvm.tar.gz" -C "$BOOTSTRAP_RUNTIMES/nvm" --strip-components=1 - track_dir "$HOME/.nvm" + track_dir "$BOOTSTRAP_RUNTIMES/nvm" - log_success "NVM source files successfully extracted to $HOME/.nvm." + log_success "NVM source files successfully extracted to $BOOTSTRAP_RUNTIMES/nvm." } configure_shell() { - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "nvm setup" - done local content content=$(cat << 'EOF' -export NVM_DIR="$HOME/.nvm" +export NVM_DIR="$BOOTSTRAP_RUNTIMES/nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Load NVM [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # Load NVM bash completion EOF @@ -76,10 +66,10 @@ EOF install_node() { # Ensure NVM is loaded in this script context - if [ -s "$HOME/.nvm/nvm.sh" ]; then + if [ -s "$BOOTSTRAP_RUNTIMES/nvm/nvm.sh" ]; then # Temporarily disable nounset as nvm.sh does not support set -u set +u - . "$HOME/.nvm/nvm.sh" + . "$BOOTSTRAP_RUNTIMES/nvm/nvm.sh" else log_error "Could not load NVM to install Node.js." return 1 @@ -95,6 +85,7 @@ install_node() { nvm alias default 'lts/*' log_success "Node.js installed successfully!" set -u + register_tool "node" "managed" "$latest_tag" "github:nvm-sh/nvm" } main() { @@ -106,7 +97,7 @@ main() { if has_command node; then log_success "Node.js (via NVM) installation and configuration complete." log_info "Installed Node version: $(node --version)" - log_info "Installed NVM version: $(nvm --version 2>/dev/null || cat "$HOME/.nvm/package.json" | grep '"version":' | head -n1 | sed -E 's/.*"version": "([^"]+)".*/\1/' || echo "unknown")" + log_info "Installed NVM version: $(nvm --version 2>/dev/null || cat "$BOOTSTRAP_RUNTIMES/nvm/package.json" | grep '"version":' | head -n1 | sed -E 's/.*"version": "([^"]+)".*/\1/' || echo "unknown")" else log_success "Installation complete." fi diff --git a/installers/install_nvim.sh b/installers/install_nvim.sh index c5e4c08..12f3ac3 100644 --- a/installers/install_nvim.sh +++ b/installers/install_nvim.sh @@ -2,20 +2,16 @@ # Tool: nvim # DisplayName: Neovim # Description: Install Neovim 0.12.0 and configuration +# Strategy: binary # # Neovim Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail NVIM_VERSION="0.12.0" -NVIM_INSTALL_DIR="/opt/nvim" +NVIM_INSTALL_DIR="$BOOTSTRAP_OPT/nvim" +NVIM_BIN_DIR="$BOOTSTRAP_BIN" NVIM_CONFIG_REPO="https://git.adityagupta.dev/sortedcord/editor.git" NVIM_CONFIG_DIR="$HOME/.config/nvim" @@ -33,7 +29,7 @@ check_config_dir() { install_packages() { log_info "Detecting distribution and installing dependencies..." pkg_install \ - git tar curl unzip ripgrep fzf nodejs npm xclip wl-clipboard \ + git tar unzip ripgrep fzf nodejs npm xclip wl-clipboard \ "arch:fd|debian:fd-find|fedora:fd-find" \ "arch:cmake|debian:cmake|fedora:cmake" \ "arch:make|debian:build-essential|fedora:make" \ @@ -76,23 +72,23 @@ install_nvim() { *) log_error "Unsupported architecture: $arch"; exit 1 ;; esac - 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}..." - download_file "$nvim_url" "$TMP_DIR/nvim.tar.gz" + github_download_asset "neovim/neovim" "v${NVIM_VERSION}" "nvim-${nvim_arch}\.tar\.gz" "$TMP_DIR/nvim.tar.gz" tar -xzf "$TMP_DIR/nvim.tar.gz" -C "$TMP_DIR" - sudo rm -rf "$NVIM_INSTALL_DIR" - sudo mv "$TMP_DIR/nvim-${nvim_arch}" "$NVIM_INSTALL_DIR" + rm -rf "$NVIM_INSTALL_DIR" + mkdir -p "$(dirname "$NVIM_INSTALL_DIR")" + mv "$TMP_DIR/nvim-${nvim_arch}" "$NVIM_INSTALL_DIR" - sudo ln -sf "$NVIM_INSTALL_DIR/bin/nvim" /usr/local/bin/nvim + ln -sf "$NVIM_INSTALL_DIR/bin/nvim" "$NVIM_BIN_DIR/nvim" track_dir "$NVIM_INSTALL_DIR" - track_file "/usr/local/bin/nvim" + track_file "$NVIM_BIN_DIR/nvim" log_success "Installed:" nvim --version | head -n1 + register_tool "nvim" "binary" "$NVIM_VERSION" "github:neovim/neovim" } install_config() { @@ -111,24 +107,6 @@ install_config() { } configure_shell() { - # Clean up legacy inline edits from bashrc and bash_aliases - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - if [ -f "$config_file" ]; then - local tmp_file - tmp_file=$(mktemp) - sed '/^export EDITOR="nvim"/d' "$config_file" > "$tmp_file" - cat "$tmp_file" > "$config_file" - rm -f "$tmp_file" - fi - done - if [ -f "$HOME/.bash_aliases" ]; then - local tmp_file - tmp_file=$(mktemp) - sed '/^alias vim="nvim"/d' "$HOME/.bash_aliases" > "$tmp_file" - cat "$tmp_file" > "$HOME/.bash_aliases" - rm -f "$tmp_file" - fi write_alias_snippet "nvim" 'alias vim="nvim"' write_env_snippet "nvim" 'export EDITOR="nvim"' diff --git a/installers/install_pnpm.sh b/installers/install_pnpm.sh index 46f8c2b..b5422ad 100644 --- a/installers/install_pnpm.sh +++ b/installers/install_pnpm.sh @@ -2,6 +2,7 @@ # Tool: pnpm # DisplayName: Pnpm # Description: Install pnpm package manager +# Strategy: binary # # pnpm Installer Script # @@ -17,12 +18,6 @@ # curl -fsSL https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash - # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -127,14 +122,17 @@ install_pnpm() { } libc_suffix="$(detect_libc_suffix)" - # Fetch the latest version from the npm registry, or use PNPM_VERSION if set + # Fetch the latest version from GitHub, or use PNPM_VERSION if set if [ -z "${PNPM_VERSION:-}" ]; then - log_info "Fetching latest pnpm version from npm registry..." - version_json="$(download "https://registry.npmjs.org/@pnpm/exe")" || { - log_error "Failed to fetch pnpm version info from npm registry." + log_info "Fetching latest pnpm version from GitHub..." + local tag + tag=$(github_get_latest_release "pnpm/pnpm") + if [ -n "$tag" ]; then + version="${tag#v}" + else + log_error "Failed to fetch pnpm version info from GitHub." return 1 - } - version="$(echo "$version_json" | grep -o '"latest":[[:space:]]*"[0-9.]*"' | grep -o '[0-9.]*')" + fi else version="${PNPM_VERSION}" fi @@ -151,7 +149,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" || { + github_download_asset "pnpm/pnpm" "v${version}" "${asset_base}\.tar\.gz" "$TMP_DIR/pnpm.tar.gz" || { log_error "Failed to download pnpm tarball." return 1 } @@ -166,7 +164,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" || { + github_download_asset "pnpm/pnpm" "v${version}" "${asset_base}" "$TMP_DIR/pnpm" || { log_error "Failed to download pnpm binary." return 1 } @@ -179,23 +177,19 @@ install_pnpm() { track_dir "$HOME/.local/share/pnpm" log_success "pnpm v${version} installed successfully!" + register_tool "pnpm" "binary" "$version" "github:pnpm/pnpm" } # ─── Shell Configuration ───────────────────────────────────────────── configure_shell() { - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "pnpm setup" - done # pnpm's `setup --force` configures PNPM_HOME and PATH automatically, # but we also add an env block to ensure PNPM_HOME is set consistently. local content content=$(cat << 'EOF' # pnpm -export PNPM_HOME="$HOME/.local/share/pnpm" +export PNPM_HOME="$BOOTSTRAP_RUNTIMES/pnpm" case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;; diff --git a/installers/install_rust.sh b/installers/install_rust.sh index e6db38f..a5eb901 100644 --- a/installers/install_rust.sh +++ b/installers/install_rust.sh @@ -2,16 +2,11 @@ # Tool: rust # DisplayName: Rust # Description: Install Rustup and Rust compiler/toolchain +# Strategy: managed # # Rust Installer Script (Simplified Local Rustup Init) # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -20,14 +15,6 @@ cleanup() { } trap cleanup EXIT -# Ensure we have curl -install_downloader() { - if ! has_command curl; then - log_info "curl not found. Installing curl..." - pkg_install curl - fi -} - detect_target_triple() { local ostype ostype="$(uname -s)" @@ -61,11 +48,14 @@ detect_target_triple() { } install_rust() { - if has_command rustup || [ -f "$HOME/.cargo/bin/rustup" ]; then + export CARGO_HOME="$BOOTSTRAP_RUNTIMES/cargo" + export RUSTUP_HOME="$BOOTSTRAP_RUNTIMES/rustup" + + if has_command rustup || [ -f "$BOOTSTRAP_RUNTIMES/cargo/bin/rustup" ]; then log_info "Rust (rustup) is already installed." fi - install_downloader + local target target=$(detect_target_triple) @@ -87,19 +77,19 @@ install_rust() { "$dest" -y --no-modify-path add_rollback_cmd "rustup self uninstall -y" + register_tool "rust" "managed" "" "rustup" } configure_shell() { - # Add ~/.cargo/bin to PATH for the current process - export PATH="$HOME/.cargo/bin:$PATH" - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "rust init" - done - write_env_snippet "rust" '. "$HOME/.cargo/env"' + local snippet_content=$(cat << 'EOF' +export CARGO_HOME="$BOOTSTRAP_RUNTIMES/cargo" +export RUSTUP_HOME="$BOOTSTRAP_RUNTIMES/rustup" +. "$CARGO_HOME/env" +EOF +) + write_env_snippet "rust" "$snippet_content" } main() { diff --git a/installers/install_starship.sh b/installers/install_starship.sh index da2d144..f84e9f0 100644 --- a/installers/install_starship.sh +++ b/installers/install_starship.sh @@ -2,16 +2,11 @@ # Tool: starship # DisplayName: Starship # Description: Install Starship shell prompt +# Strategy: binary # # Starship Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -25,12 +20,6 @@ install_starship() { log_info "Starship is already installed." fi - # Ensure curl is installed - if ! has_command curl; then - log_info "curl not found. Installing curl..." - pkg_install curl - fi - # Detect architecture local raw_arch raw_arch=$(detect_arch) @@ -45,47 +34,33 @@ install_starship() { log_info "Fetching latest Starship version from GitHub..." local latest_tag="" - latest_tag=$(curl -sL https://api.github.com/repos/starship/starship/releases/latest | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) - - local download_url - if [ -n "$latest_tag" ]; then - log_info "Latest Starship version found: $latest_tag" - download_url="https://github.com/starship/starship/releases/download/${latest_tag}/starship-${target}.tar.gz" - else + latest_tag=$(github_get_latest_release "starship/starship") + + if [ -z "$latest_tag" ]; then latest_tag="latest" - log_warn "Failed to fetch latest version from GitHub. Falling back to downloading latest release directly." - download_url="https://github.com/starship/starship/releases/latest/download/starship-${target}.tar.gz" fi - log_info "Downloading Starship from ${download_url}..." + log_info "Downloading Starship ${latest_tag}..." local archive="$TMP_DIR/starship.tar.gz" - download_file "$download_url" "$archive" + github_download_asset "starship/starship" "$latest_tag" "starship-${target}\.tar\.gz" "$archive" # Extract the binary log_info "Extracting Starship binary..." tar -xzf "$archive" -C "$TMP_DIR" # Install to ~/.local/bin - local target_dir="$HOME/.local/bin" + local target_dir="$BOOTSTRAP_BIN" mkdir -p "$target_dir" log_info "Installing Starship to $target_dir/starship..." cp "$TMP_DIR/starship" "$target_dir/starship" chmod +x "$target_dir/starship" track_file "$target_dir/starship" + register_tool "starship" "binary" "$latest_tag" "github:starship/starship" } configure_shell() { - # Add ~/.local/bin to PATH for the current process - export PATH="$HOME/.local/bin:$PATH" - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "local-bin path" - remove_block "$config_file" "starship init" - done - write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"' write_env_snippet "starship" 'eval "$(starship init bash)"' } diff --git a/installers/install_uv.sh b/installers/install_uv.sh index 64ca56a..ff14677 100644 --- a/installers/install_uv.sh +++ b/installers/install_uv.sh @@ -2,16 +2,11 @@ # Tool: uv # DisplayName: uv # Description: Fast Python package installer and resolver +# Strategy: binary # # uv Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -28,12 +23,6 @@ install_uv() { fi fi - # Ensure curl is installed - if ! has_command curl; then - log_info "curl not found. Installing curl..." - pkg_install curl - fi - # Detect architecture local raw_arch raw_arch=$(detect_arch) @@ -54,28 +43,22 @@ install_uv() { log_info "Fetching latest uv version from GitHub..." local latest_tag="" - latest_tag=$(curl -sL https://api.github.com/repos/astral-sh/uv/releases/latest | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) + latest_tag=$(github_get_latest_release "astral-sh/uv") - local download_url - if [ -n "$latest_tag" ]; then - log_info "Latest uv version found: $latest_tag" - download_url="https://github.com/astral-sh/uv/releases/download/${latest_tag}/uv-${target}.tar.gz" - else + if [ -z "$latest_tag" ]; then latest_tag="latest" - log_warn "Failed to fetch latest version from GitHub. Falling back to downloading latest release directly." - download_url="https://github.com/astral-sh/uv/releases/latest/download/uv-${target}.tar.gz" fi - log_info "Downloading uv from ${download_url}..." + log_info "Downloading uv ${latest_tag}..." local archive="$TMP_DIR/uv.tar.gz" - download_file "$download_url" "$archive" + github_download_asset "astral-sh/uv" "$latest_tag" "uv-${target}\.tar\.gz" "$archive" # Extract the binaries log_info "Extracting uv binaries..." tar -xzf "$archive" --strip-components 1 -C "$TMP_DIR" # Install to ~/.local/bin - local target_dir="$HOME/.local/bin" + local target_dir="$BOOTSTRAP_BIN" mkdir -p "$target_dir" log_info "Installing uv and uvx to $target_dir..." cp "$TMP_DIR/uv" "$target_dir/uv" @@ -83,20 +66,12 @@ install_uv() { chmod +x "$target_dir/uv" "$target_dir/uvx" track_file "$target_dir/uv" track_file "$target_dir/uvx" + register_tool "uv" "binary" "$latest_tag" "github:astral-sh/uv" } configure_shell() { - # Add ~/.local/bin to PATH for the current process - export PATH="$HOME/.local/bin:$PATH" - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "local-bin path" - remove_block "$config_file" "uv completion" - done - write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"' write_env_snippet "uv" 'eval "$(uv generate-shell-completion bash)"' } diff --git a/installers/install_yay.sh b/installers/install_yay.sh index aaa5a8d..105f0b9 100755 --- a/installers/install_yay.sh +++ b/installers/install_yay.sh @@ -2,16 +2,11 @@ # Tool: yay # DisplayName: Yay # Description: Install Yay AUR helper +# Strategy: system # # Yay Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail # ─── Installation Logic ────────────────────────────────────────────── @@ -66,6 +61,7 @@ install_yay() { cd "$orig_dir" log_info "Cleaning up installer directory..." rm -rf "$clone_dir" + register_tool "yay" "system" "" "aur:yay-bin" } # ─── Main ───────────────────────────────────────────────────────────── diff --git a/installers/install_yazi.sh b/installers/install_yazi.sh index 5d6fc9d..e2bf1f8 100755 --- a/installers/install_yazi.sh +++ b/installers/install_yazi.sh @@ -2,16 +2,11 @@ # Tool: yazi # DisplayName: Yazi # Description: Install Yazi terminal file manager and dependencies +# Strategy: binary # # Yazi Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail TMP_DIR="$(make_temp_dir)" @@ -21,11 +16,6 @@ cleanup() { trap cleanup EXIT add_y_wrapper() { - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "yazi wrapper" - done local wrapper_content wrapper_content=$(cat << 'EOF' @@ -40,77 +30,68 @@ y() { } EOF ) - write_alias_snippet "yazi" "$wrapper_content" } install_yazi() { - local distro - distro=$(detect_distro) - - if [ "$distro" = "arch" ]; then - log_info "Arch Linux detected" - if has_command yazi; then - log_info "Yazi is already installed." + if has_command yazi; then + if ! confirm "Yazi is already installed. Reinstall/Upgrade?"; then + log_info "Skipping Yazi installation." + return fi - - log_info "Installing Yazi..." - pkg_install yazi - log_info "Installing dependencies subsequently..." - pkg_install ffmpeg 7zip jq poppler fd ripgrep fzf zoxide resvg imagemagick - - elif [ "$distro" = "debian" ]; then - log_info "Debian/Ubuntu detected" - if has_command yazi; then - log_info "Yazi is already installed." - fi - - pkg_install curl git - - log_info "Fetching latest Yazi version from GitHub..." - local latest_tag - latest_tag=$(curl -sL https://api.github.com/repos/sxyazi/yazi/releases/latest | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) - if [ -z "$latest_tag" ]; then - latest_tag="v26.5.6" - fi - - 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}..." - download_file "$deb_url" "$TMP_DIR/yazi.deb" - - log_info "Installing Yazi package..." - sudo apt install -y "$TMP_DIR/yazi.deb" - add_rollback_cmd "sudo apt remove -y yazi" - - log_info "Installing dependencies subsequently..." - pkg_install ffmpeg jq poppler-utils fd-find ripgrep fzf zoxide resvg imagemagick 7zip || \ - pkg_install ffmpeg jq poppler-utils fd-find ripgrep fzf zoxide resvg imagemagick p7zip-full - - create_fd_symlink - - elif [ "$distro" = "fedora" ]; then - log_info "Fedora detected" - if has_command yazi; then - log_info "Yazi is already installed." - fi - - log_info "Installing dnf-plugins-core..." - pkg_install dnf-plugins-core - - log_info "Enabling lihaohong/yazi copr repo..." - sudo dnf copr enable -y lihaohong/yazi - - log_info "Installing Yazi (without weak dependencies first)..." - sudo dnf install -y yazi --setopt=install_weak_deps=False - add_rollback_cmd "sudo dnf remove -y yazi" - - log_info "Installing weak dependencies subsequently..." - pkg_install yazi - - else - log_error "Unsupported distribution." - exit 1 fi + + # Ensure required extraction tools are installed + if ! has_command unzip; then + log_info "unzip not found. Installing unzip..." + pkg_install unzip + fi + + local arch + arch=$(detect_arch) + local target="" + case "$arch" in + x86_64) target="x86_64-unknown-linux-gnu" ;; + arm64) target="aarch64-unknown-linux-gnu" ;; + *) log_error "Unsupported architecture: $arch"; exit 1 ;; + esac + + log_info "Fetching latest Yazi version from GitHub..." + local latest_tag="" + latest_tag=$(github_get_latest_release "sxyazi/yazi") + + if [ -z "$latest_tag" ]; then + latest_tag="v0.3.3" + log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag" + fi + + log_info "Downloading Yazi ${latest_tag}..." + local archive="$TMP_DIR/yazi.zip" + github_download_asset "sxyazi/yazi" "$latest_tag" "yazi-${target}\.zip" "$archive" + + log_info "Extracting Yazi binaries..." + unzip -q "$archive" -d "$TMP_DIR" + + local extract_dir="$TMP_DIR/yazi-${target}" + local target_dir="$BOOTSTRAP_BIN" + mkdir -p "$target_dir" + + log_info "Installing Yazi to $target_dir..." + cp "$extract_dir/yazi" "$target_dir/yazi" + cp "$extract_dir/ya" "$target_dir/ya" + chmod +x "$target_dir/yazi" "$target_dir/ya" + track_file "$target_dir/yazi" + track_file "$target_dir/ya" + + log_info "Installing system dependencies for Yazi..." + pkg_install ffmpeg jq ripgrep fzf zoxide resvg imagemagick "arch:7zip|debian:7zip|fedora:p7zip" "arch:poppler|debian:poppler-utils|fedora:poppler-utils" "arch:fd|debian:fd-find|fedora:fd-find" + + create_fd_symlink + + register_tool "yazi" "binary" "$latest_tag" "github:sxyazi/yazi" + + # Add the system dependencies to the registry for uninstallation tracking + registry_add_sys_deps "yazi" ffmpeg jq ripgrep fzf zoxide resvg imagemagick "arch:7zip|debian:7zip|fedora:p7zip" "arch:poppler|debian:poppler-utils|fedora:poppler-utils" "arch:fd|debian:fd-find|fedora:fd-find" } main() { diff --git a/installers/install_zoxide.sh b/installers/install_zoxide.sh index 110baa3..6428906 100755 --- a/installers/install_zoxide.sh +++ b/installers/install_zoxide.sh @@ -2,24 +2,13 @@ # Tool: zoxide # DisplayName: Zoxide # Description: Install Zoxide directory jumper +# Strategy: managed # # Zoxide Installer Script # -# Prevent standalone execution -if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then - echo "Error: This script must be run through the 'b' CLI." >&2 - exit 1 -fi - set -euo pipefail -install_curl() { - if ! has_command curl; then - log_info "curl not found. Installing curl..." - pkg_install curl - fi -} install_fzf() { if has_command fzf; then @@ -36,24 +25,17 @@ install_zoxide() { log_info "Zoxide is already installed." fi - install_curl + log_info "Downloading and running the official zoxide installer..." curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh track_file "$HOME/.local/bin/zoxide" + register_tool "zoxide" "managed" "" "github:ajeetdsouza/zoxide" } configure_shell() { - # Add ~/.local/bin to PATH for the current process - export PATH="$HOME/.local/bin:$PATH" - # Clean up legacy in-place configuration blocks - IFS=' ' read -ra target_files <<< "$(get_shell_configs)" - for config_file in "${target_files[@]}"; do - remove_block "$config_file" "zoxide init" - done - write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"' write_env_snippet "zoxide" 'eval "$(zoxide init --cmd cd bash)"' } diff --git a/lib/common.sh b/lib/common.sh index ac96f45..10d1151 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -7,6 +7,15 @@ if [ -n "${_LIB_COMMON_SOURCED:-}" ]; then fi _LIB_COMMON_SOURCED=1 +# Export global environment paths with default fallbacks +export BOOTSTRAP_DIR="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}" +export BOOTSTRAP_DATA_DIR="${BOOTSTRAP_DATA_DIR:-$HOME/.local/share/bootstrap}" +export BOOTSTRAP_STATE_DIR="${BOOTSTRAP_STATE_DIR:-$HOME/.local/state/bootstrap}" +export BOOTSTRAP_CACHE_DIR="${BOOTSTRAP_CACHE_DIR:-$HOME/.cache/bootstrap}" +export BOOTSTRAP_BIN="${BOOTSTRAP_BIN:-$BOOTSTRAP_DATA_DIR/bin}" +export BOOTSTRAP_OPT="${BOOTSTRAP_OPT:-$BOOTSTRAP_DATA_DIR/opt}" +export BOOTSTRAP_RUNTIMES="${BOOTSTRAP_RUNTIMES:-$BOOTSTRAP_DATA_DIR/runtimes}" + # Ensure running in Bash require_bash() { if [ -z "${BASH_VERSION:-}" ]; then @@ -88,7 +97,7 @@ version_lt() { download_file() { local url="$1" local dest="$2" - local cache_dir="$HOME/.local/state/bootstrap/cache" + local cache_dir="$BOOTSTRAP_CACHE_DIR/downloads" mkdir -p "$cache_dir" diff --git a/lib/github.sh b/lib/github.sh new file mode 100644 index 0000000..5f7d9e4 --- /dev/null +++ b/lib/github.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# GitHub API helper functions for Bootstrap installers + +# Usage: github_get_latest_release +# Prints the tag_name of the latest release. + +# Installers still use this function instead of just directly invoking download_asset function: +# - Asset names often contain the version +# - Installers may compare the latest tag from github against the locally installed version before doing any work. +# - We need concrete version string so we can pass it to the reigster_tool function. +github_get_latest_release() { + local repo="$1" + local tag + tag=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" | jq -r '.tag_name // empty') + echo "$tag" +} + +# Usage: github_get_download_url +# Finds the asset matching the regex pattern in the specified release tag and prints its download URL. +github_get_download_url() { + local repo="$1" + local tag="$2" + local pattern="$3" + + # If the tag is exactly 'latest', fetch the latest release asset list + local endpoint + if [ "$tag" = "latest" ]; then + endpoint="https://api.github.com/repos/$repo/releases/latest" + else + endpoint="https://api.github.com/repos/$repo/releases/tags/$tag" + fi + + local url + url=$(curl -fsSL "$endpoint" | jq -r --arg regex "$pattern" '.assets[] | select(.name | test($regex; "i")) | .browser_download_url' | head -n1) + echo "$url" +} + +# Usage: github_download_asset +# Resolves the URL for the matching asset and downloads it to dest_file. +github_download_asset() { + local repo="$1" + local tag="$2" + local pattern="$3" + local dest="$4" + + local url + url=$(github_get_download_url "$repo" "$tag" "$pattern") + + if [ -z "$url" ]; then + log_error "Could not find asset matching regex '$pattern' for $repo@$tag" + return 1 + fi + + log_info "Downloading $url ..." + download_file "$url" "$dest" +} + +export -f github_get_latest_release github_get_download_url github_download_asset diff --git a/lib/json.sh b/lib/json.sh deleted file mode 100644 index fa85bb8..0000000 --- a/lib/json.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash - -# generic JSON parser in pure bash and awk. -# reads JSON from stdin and outputs a flattened list of key-value pairs. -# example input: {"plugins": {"my_plugin": {"version": "1.0", "arr": [1, 2]}}} -# example output: - # plugins.my_plugin.version="1.0" - # plugins.my_plugin.arr[0]=1 - # plugins.my_plugin.arr[1]=2 - -# pardon my french -parse_json() { - # Tokenize the JSON using grep - grep -oE '"([^"\\]|\\.)*"|true|false|null|[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?|[][}{:,]' | \ - awk ' - BEGIN { - depth=0; - key="" - } - { - token = $0 - if (token == "{") { - depth++ - is_key[depth] = 1 - array_idx[depth] = "" - } else if (token == "}") { - delete path[depth] - delete array_idx[depth] - depth-- - } else if (token == "[") { - depth++ - is_key[depth] = 0 - array_idx[depth] = 0 - } else if (token == "]") { - delete array_idx[depth] - delete path[depth] - depth-- - } else if (token == ":") { - is_key[depth] = 0 - } else if (token == ",") { - if (array_idx[depth] != "") { - array_idx[depth]++ - } else { - is_key[depth] = 1 - } - } else { - if (is_key[depth] == 1) { - # Remove quotes from the key - gsub(/^"|"$/, "", token) - path[depth] = token - } else { - # It is a value - p = "" - for (i=1; i<=depth; i++) { - if (array_idx[i] != "") { - p = p "[" array_idx[i] "]" - } else if (path[i] != "") { - p = p "." path[i] - } - } - # Remove leading dot - sub(/^\./, "", p) - print p "=" token - } - } - } - ' -} diff --git a/lib/platform.sh b/lib/platform.sh index 3bba570..9cd8a10 100644 --- a/lib/platform.sh +++ b/lib/platform.sh @@ -86,18 +86,7 @@ pkg_install() { if ! pkg_check "$pkg"; then to_install+=("$pkg") fi - - # Reference counting logic - if [ -n "${BOOTSTRAP_CURRENT_TOOL:-}" ] && [ -n "${BOOTSTRAP_PACKAGES_DIR:-}" ]; then - local ref_file="$BOOTSTRAP_PACKAGES_DIR/$pkg" - if ! grep -q "^${BOOTSTRAP_CURRENT_TOOL}$" "$ref_file" 2>/dev/null; then - echo "$BOOTSTRAP_CURRENT_TOOL" >> "$ref_file" - # Register rollback command - if type add_rollback_cmd >/dev/null 2>&1; then - add_rollback_cmd "pkg_remove $pkg" - fi - fi - fi + done if [ ${#to_install[@]} -eq 0 ]; then @@ -169,21 +158,15 @@ pkg_remove() { local to_remove=() for pkg in "${pkgs[@]}"; do - if [ -n "${BOOTSTRAP_CURRENT_TOOL:-}" ] && [ -n "${BOOTSTRAP_PACKAGES_DIR:-}" ]; then - local ref_file="$BOOTSTRAP_PACKAGES_DIR/$pkg" - if [ -f "$ref_file" ]; then - # Remove this tool from the reference file - sed -i "/^${BOOTSTRAP_CURRENT_TOOL}$/d" "$ref_file" - if [ -s "$ref_file" ]; then - log_info "Skipping removal of '$pkg'; it is required by other tools." - continue - else - rm -f "$ref_file" - fi - fi + local is_installed=0 + if pkg_check "$pkg"; then + is_installed=1 fi + - to_remove+=("$pkg") + if [ "$is_installed" -eq 1 ]; then + to_remove+=("$pkg") + fi done if [ ${#to_remove[@]} -eq 0 ]; then diff --git a/lib/plugins.sh b/lib/plugins.sh index c19400e..bd44d6d 100644 --- a/lib/plugins.sh +++ b/lib/plugins.sh @@ -1,50 +1,13 @@ #!/usr/bin/env bash -if [ -f "$BOOTSTRAP_DIR/lib/json.sh" ]; then - . "$BOOTSTRAP_DIR/lib/json.sh" -fi - -# Parses a plugin manifest using the generic json parser and outputs bash array assignments +# Parses a plugin manifest using jq and outputs bash array assignments parse_plugin_manifest() { - # The generic parser outputs lines like: - # plugins.myplugin.version="1.0" - # plugins.myplugin.url="https://..." - # We want to extract myplugin and the keys to build: - # PLUGIN_VERSIONS["myplugin"]="1.0" - # PLUGIN_URLS["myplugin"]="https://..." - - parse_json | awk -F'=' ' - { - path = $1 - val = $2 - - # Remove quotes around value for bash array assignment - gsub(/^"|"$/, "", val) - - # Match paths starting with "plugins." - if (match(path, /^plugins\./)) { - rest = substr(path, RLENGTH + 1) - # Find the last dot to separate plugin name from the property key - last_dot = 0 - for (i=length(rest); i>0; i--) { - if (substr(rest, i, 1) == ".") { - last_dot = i - break - } - } - if (last_dot > 0) { - plugin_name = substr(rest, 1, last_dot - 1) - prop = substr(rest, last_dot + 1) - if (prop == "version") { - print "PLUGIN_VERSIONS[\"" plugin_name "\"]=\"" val "\"" - } else if (prop == "url") { - print "PLUGIN_URLS[\"" plugin_name "\"]=\"" val "\"" - } else if (prop == "bootstrap") { - print "PLUGIN_BOOTSTRAP_VERSIONS[\"" plugin_name "\"]=\"" val "\"" - } - } - } - }' + jq -r ' + .plugins | to_entries[] | + (if .value.version then "PLUGIN_VERSIONS[\"" + .key + "\"]=\"" + .value.version + "\"" else empty end), + (if .value.url then "PLUGIN_URLS[\"" + .key + "\"]=\"" + .value.url + "\"" else empty end), + (if .value.bootstrap then "PLUGIN_BOOTSTRAP_VERSIONS[\"" + .key + "\"]=\"" + .value.bootstrap + "\"" else empty end) + ' } # Ensures that the plugin sources file exists, initializing it with the official repository by default diff --git a/lib/registry.sh b/lib/registry.sh index abbb9b4..d465f00 100644 --- a/lib/registry.sh +++ b/lib/registry.sh @@ -34,4 +34,21 @@ declare -A INSTALLER_DISPLAYS=( [zoxide]="Zoxide" ) +declare -A INSTALLER_STRATEGIES=( + [agy]="binary" + [asciicinema]="binary" + [bat]="binary" + [docker]="system" + [lazygit]="binary" + [node]="managed" + [nvim]="binary" + [pnpm]="binary" + [rust]="managed" + [starship]="binary" + [uv]="binary" + [yay]="system" + [yazi]="binary" + [zoxide]="managed" +) + INSTALLER_KEYS=(agy asciicinema bat docker lazygit node nvim pnpm rust starship uv yay yazi zoxide) diff --git a/lib/registry_helpers.sh b/lib/registry_helpers.sh new file mode 100644 index 0000000..1d9ae79 --- /dev/null +++ b/lib/registry_helpers.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Registry management helpers for Bootstrap + +# Ensures the registry file exists +ensure_registry() { + local registry_file="$BOOTSTRAP_STATE_DIR/registry.json" + if [ ! -f "$registry_file" ]; then + mkdir -p "$(dirname "$registry_file")" + echo '{"tools": {}}' > "$registry_file" + fi + echo "$registry_file" +} + +# Safely applies a jq filter to the registry using a file lock +registry_set() { + local jq_filter="$1" + shift + local registry_file + registry_file=$(ensure_registry) + local lock_file="${registry_file}.lock" + + ( + flock -x 200 + local temp_file + temp_file=$(mktemp) + # Apply jq filter with any additional arguments passed in + jq "$@" "$jq_filter" "$registry_file" > "$temp_file" && mv "$temp_file" "$registry_file" + ) 200>"$lock_file" +} + +# Usage: register_tool [version] [source] +register_tool() { + local tool="$1" + local strategy="$2" + local version="${3:-}" + local source="${4:-}" + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + local bindir="$BOOTSTRAP_BIN" + + local filter='if .tools == null then .tools = {} else . end | + .tools[$tool].strategy = $strategy | + .tools[$tool].installed_at = $timestamp | + (if $version != "" then .tools[$tool].version = $version else . end) | + (if $source != "" then .tools[$tool].source = $source else . end) | + (if $strategy == "binary" then .tools[$tool].bin = ($bindir + "/" + $tool) else . end)' + + registry_set "$filter" \ + --arg tool "$tool" \ + --arg strategy "$strategy" \ + --arg version "$version" \ + --arg source "$source" \ + --arg timestamp "$timestamp" \ + --arg bindir "$bindir" +} + +# Usage: registry_add_sys_deps ... +registry_add_sys_deps() { + local tool="$1" + shift + if [ $# -eq 0 ]; then + return 0 + fi + + local deps_json + deps_json=$(printf '%s\n' "$@" | jq -R . | jq -s .) + + local filter='if .tools == null then .tools = {} else . end | + .tools[$tool].system_dependencies = ((.tools[$tool].system_dependencies // []) + $deps | unique)' + + registry_set "$filter" --arg tool "$tool" --argjson deps "$deps_json" +} + +# Usage: registry_remove_tool +registry_remove_tool() { + local tool="$1" + registry_set 'del(.tools[$tool])' --arg tool "$tool" +} + +# Usage: registry_get_sys_deps +registry_get_sys_deps() { + local tool="$1" + local registry_file="$BOOTSTRAP_STATE_DIR/registry.json" + if [ -f "$registry_file" ]; then + jq -r --arg tool "$tool" '.tools[$tool].system_dependencies[]? // empty' "$registry_file" + fi +} + +# Usage: registry_check +# Validates that a tool is actually installed according to its strategy +registry_check() { + local tool="$1" + local registry_file + registry_file=$(ensure_registry) + + local strategy + strategy=$(jq -r --arg tool "$tool" '.tools[$tool].strategy // empty' "$registry_file") + + if [ -z "$strategy" ]; then + return 1 + fi + + if [ "$strategy" = "binary" ]; then + local bin_path + bin_path=$(jq -r --arg tool "$tool" '.tools[$tool].bin // empty' "$registry_file") + if [ -n "$bin_path" ] && [ -x "$bin_path" ]; then + return 0 + fi + elif [ "$strategy" = "managed" ]; then + if command -v "$tool" >/dev/null 2>&1; then + return 0 + fi + elif [ "$strategy" = "system" ]; then + local deps=() + while IFS= read -r dep; do + [ -n "$dep" ] && deps+=("$dep") + done < <(registry_get_sys_deps "$tool") + + if [ ${#deps[@]} -eq 0 ]; then + if command -v "$tool" >/dev/null 2>&1; then + return 0 + fi + else + if pkg_check "${deps[@]}"; then + return 0 + fi + fi + fi + + return 1 +} + +export -f ensure_registry registry_set register_tool registry_add_sys_deps registry_remove_tool registry_get_sys_deps registry_check diff --git a/lib/rollback.sh b/lib/rollback.sh index 8bd8cd6..00f20f4 100644 --- a/lib/rollback.sh +++ b/lib/rollback.sh @@ -8,11 +8,11 @@ _LIB_ROLLBACK_SOURCED=1 BOOTSTRAP_STATE_DIR="$HOME/.local/state/bootstrap" BOOTSTRAP_HISTORY_LOG="$BOOTSTRAP_STATE_DIR/history.log" BOOTSTRAP_UNINSTALLERS_DIR="$BOOTSTRAP_STATE_DIR/uninstallers" -BOOTSTRAP_PACKAGES_DIR="$BOOTSTRAP_STATE_DIR/packages" + init_rollback_system() { mkdir -p "$BOOTSTRAP_UNINSTALLERS_DIR" - mkdir -p "$BOOTSTRAP_PACKAGES_DIR" + touch "$BOOTSTRAP_HISTORY_LOG" } @@ -51,6 +51,13 @@ track_dir() { create_savepoint() { local name="$1" + + # Prevent savepoints from having the same name as a tool + if [ -n "${INSTALLERS[$name]:-}" ]; then + log_error "Cannot create savepoint named '$name' because it conflicts with a tool name." + return 1 + fi + echo "SAVEPOINT: $name" >> "$BOOTSTRAP_HISTORY_LOG" log_success "Savepoint '$name' created." } @@ -84,6 +91,41 @@ execute_rollback() { log_success "Rollback of '$tool' complete." } + +uninstall_tool() { + local tool="$1" + + # 1. Execute the rollback manifest to remove files/dirs/env/aliases + execute_rollback "$tool" + + # 2. Reference counting and cleanup of system dependencies + local registry_file="$BOOTSTRAP_STATE_DIR/registry.json" + if [ -f "$registry_file" ] && jq -e --arg tool "$tool" '.tools | has($tool)' "$registry_file" >/dev/null; then + while IFS= read -r dep; do + [ -z "$dep" ] && continue + local other_users + other_users=$(jq -r --arg tool "$tool" --arg dep "$dep" ' + .tools | to_entries | map(select(.key != $tool and (.value.system_dependencies | type == "array") and (.value.system_dependencies | index($dep)))) | length + ' "$registry_file") + + if [ "$other_users" -eq 0 ]; then + log_info "System dependency '$dep' is no longer required by any registered tool. Removing..." + pkg_remove "$dep" + else + log_info "Keeping system dependency '$dep' (required by other tools)" + fi + done < <(registry_get_sys_deps "$tool") + + # Remove from registry + registry_remove_tool "$tool" + fi + + # 3. Remove the tool from history.log + if [ -f "$BOOTSTRAP_HISTORY_LOG" ]; then + sed -i "/^INSTALL: ${tool}$/d" "$BOOTSTRAP_HISTORY_LOG" + fi +} + rollback_bare() { if [ ! -s "$BOOTSTRAP_HISTORY_LOG" ]; then log_info "No history available to rollback." @@ -95,9 +137,7 @@ rollback_bare() { if [[ "$last_line" == INSTALL:* ]]; then local tool="${last_line#INSTALL: }" - execute_rollback "$tool" - # Remove the last line efficiently - sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG" + uninstall_tool "$tool" elif [[ "$last_line" == SAVEPOINT:* ]]; then local sp="${last_line#SAVEPOINT: }" log_warn "Last action was savepoint '$sp'. Cannot bare-rollback a savepoint." @@ -122,8 +162,7 @@ rollback_to_savepoint() { break elif [[ "$last_line" == INSTALL:* ]]; then local tool="${last_line#INSTALL: }" - execute_rollback "$tool" - sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG" + uninstall_tool "$tool" elif [[ "$last_line" == SAVEPOINT:* ]]; then local sp="${last_line#SAVEPOINT: }" log_info "Removing intermediate savepoint '$sp'..." @@ -135,4 +174,4 @@ rollback_to_savepoint() { done } -export -f init_rollback_system setup_uninstaller_context add_rollback_cmd track_file track_dir create_savepoint mark_install_success execute_rollback rollback_bare rollback_to_savepoint +export -f init_rollback_system setup_uninstaller_context add_rollback_cmd track_file track_dir create_savepoint mark_install_success execute_rollback uninstall_tool rollback_bare rollback_to_savepoint diff --git a/lib/routes.sh b/lib/routes.sh index d41cf80..157a747 100755 --- a/lib/routes.sh +++ b/lib/routes.sh @@ -275,7 +275,12 @@ for script in "${SCRIPTS[@]}"; do if [ -z "$target" ]; then rollback_bare else - rollback_to_savepoint "$target" + local registry_file="$BOOTSTRAP_STATE_DIR/registry.json" + if [ -f "$registry_file" ] && jq -e --arg t "$target" '.tools | has($t)' "$registry_file" >/dev/null; then + uninstall_tool "$target" + else + rollback_to_savepoint "$target" + fi fi exit 0 ;; diff --git a/scripts/generate_registry.sh b/scripts/generate_registry.sh index e95a916..e3cbf99 100755 --- a/scripts/generate_registry.sh +++ b/scripts/generate_registry.sh @@ -13,6 +13,7 @@ echo "==> Generating registry.sh..." # Temporary arrays declare -A tools_desc declare -A tools_disp +declare -A tools_strat keys=() for f in "$INSTALLERS_DIR"/install_*.sh; do @@ -20,10 +21,12 @@ for f in "$INSTALLERS_DIR"/install_*.sh; do tool=$(grep -E "^# Tool:" "$f" | head -n1 | sed -E 's/^# Tool:\s*//I') disp_name=$(grep -E "^# DisplayName:" "$f" | head -n1 | sed -E 's/^# DisplayName:\s*//I') desc=$(grep -E "^# Description:" "$f" | head -n1 | sed -E 's/^# Description:\s*//I' | sed -E 's/^Install\s+//I') + strat=$(grep -E "^# Strategy:" "$f" | head -n1 | sed -E 's/^# Strategy:\s*//I') if [ -n "$tool" ]; then tools_desc["$tool"]="$desc" tools_disp["$tool"]="${disp_name:-$tool}" + tools_strat["$tool"]="${strat:-unknown}" keys+=("$tool") fi done @@ -49,6 +52,13 @@ sorted_keys=($(printf '%s\n' "${keys[@]}" | sort)) done echo ")" echo "" + echo "declare -A INSTALLER_STRATEGIES=(" + for k in "${sorted_keys[@]}"; do + escaped_strat=$(echo "${tools_strat[$k]}" | sed 's/"/\\"/g') + echo " [$k]=\"$escaped_strat\"" + done + echo ")" + echo "" # Format keys output as space-separated list in array declaration format echo "INSTALLER_KEYS=(${sorted_keys[*]})" } > "$REGISTRY_FILE"