11 Commits

22 changed files with 681 additions and 206 deletions

View File

@@ -1 +1 @@
1.2.1
2.0.0

View File

@@ -41,7 +41,7 @@ else
BOOTSTRAP_SOURCE_DIR="$BOOTSTRAP_TMP_DIR"
_BASE_URL="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master"
_LIBS=("lib/common.sh" "lib/platform.sh" "lib/shell_config.sh")
_LIBS=("lib/common.sh" "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh")
_curl_args=()
for _lib in "${_LIBS[@]}"; do
@@ -53,8 +53,10 @@ fi
if [ -f "$BOOTSTRAP_SOURCE_DIR/lib/common.sh" ]; then
. "$BOOTSTRAP_SOURCE_DIR/lib/common.sh"
. "$BOOTSTRAP_SOURCE_DIR/lib/rollback.sh"
. "$BOOTSTRAP_SOURCE_DIR/lib/platform.sh"
. "$BOOTSTRAP_SOURCE_DIR/lib/shell_config.sh"
init_rollback_system
else
echo "Error: Failed to locate or download bootstrap libraries." >&2
exit 1
@@ -67,6 +69,8 @@ install_bootstrap() {
local routes_dir="$HOME/.config/bootstrap"
mkdir -p "$routes_dir"
mkdir -p "$routes_dir/env.d"
mkdir -p "$routes_dir/aliases.d"
# List of all files to download/copy
local files=(
@@ -75,6 +79,7 @@ install_bootstrap() {
"lib/routes.sh"
"lib/registry.sh"
"lib/common.sh"
"lib/rollback.sh"
"lib/platform.sh"
"lib/shell_config.sh"
"commands/help.sh"
@@ -126,13 +131,15 @@ install_bootstrap() {
# 2. Clean up old loader block if it exists
remove_block "$config_file" "bootstrap-cli setup"
# 3. Append the new lightweight loader block
# 3. Append the new lightweight loader block that sources modular configs
log_info "Adding bootstrap loader to $config_file..."
cat << 'EOF' >> "$config_file"
# >>> bootstrap-cli setup >>>
export BOOTSTRAP_DIR="$HOME/.config/bootstrap"
[ -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
# <<< bootstrap-cli setup <<<
EOF

107
docs/rollback_design.md Normal file
View File

@@ -0,0 +1,107 @@
# Bootstrap CLI: Procedural Rollback System Design
## 1. Objective
Provide a robust rollback mechanism by dynamically generating an uninstallation command list during the installation process. This avoids external parsers, keeps dependencies low, and leverages native Bash execution. It also includes a stateful savepoint system to revert complex environments.
## 2. Core Concept: Dynamic Command Manifests
Instead of tracking state in data files (like JSON), the system procedurally builds a **Command Manifest** (`~/.local/state/bootstrap/uninstallers/<tool>.cmds`) as the installation progresses. Every helper action records its inverse command as an independent line in this manifest.
## 3. History & Savepoints (`b fall` and `b rb`)
To allow rolling back multiple installations or returning to a known good state, the system maintains a chronological **History Log** acting as a stack.
**Location:** `~/.local/state/bootstrap/history.log`
### A. Creating a Savepoint (`b fall <name>`)
The `b fall` command simply appends a marker to the history log.
```bash
echo "SAVEPOINT: $name" >> "$HOME/.local/state/bootstrap/history.log"
```
### B. Tracking Installations
Whenever an installation successfully completes, the `b` CLI appends an install marker:
```bash
echo "INSTALL: nvim" >> "$HOME/.local/state/bootstrap/history.log"
```
**Example History Log:**
```text
SAVEPOINT: init
INSTALL: rust
INSTALL: node
SAVEPOINT: dev_setup
INSTALL: yazi
INSTALL: nvim
```
### C. Bare Rollback (`b rb`)
When `b rb` is executed without arguments, it rolls back the single most recent change:
1. Reads the last line of the history log (e.g., `INSTALL: nvim`).
2. Executes the command manifest for `nvim`.
3. Deletes the last line from the history log.
### D. Savepoint Rollback (`b rb <name>`)
When `b rb init` is executed, it rolls back all changes made after that savepoint:
1. Parses the history log from bottom to top.
2. For each `INSTALL: <tool>` encountered, it executes the rollback manifest for `<tool>`.
3. Stops when it reaches `SAVEPOINT: init`.
4. Truncates the history log back to the savepoint.
## 4. Required Abstractions & Helper Modifications
### A. Context Initialization
Before executing an installer script, the `b` CLI initializes the command list:
```bash
export BOOTSTRAP_UNINSTALLER_CMDS="$HOME/.local/state/bootstrap/uninstallers/nvim.cmds"
mkdir -p "$(dirname "$BOOTSTRAP_UNINSTALLER_CMDS")"
touch "$BOOTSTRAP_UNINSTALLER_CMDS"
```
### B. Recording Commands (LIFO Execution)
Rollback steps are safest when executed in reverse order. A helper prepends commands to the top of the manifest.
```bash
add_rollback_cmd() {
local cmd="$1"
sed -i "1i $cmd" "$BOOTSTRAP_UNINSTALLER_CMDS"
}
```
### C. Modifying Existing Helpers
Existing helpers automatically generate their own inverse commands.
- **`pkg_install`:**
```bash
add_rollback_cmd "pkg_remove $pkg"
```
- **`write_env_snippet` / `write_alias_snippet`:**
```bash
add_rollback_cmd "rm -f \"$HOME/.config/bootstrap/env.d/$snippet_name.sh\""
```
### D. New File Tracking Helpers
```bash
track_file() { add_rollback_cmd "sudo rm -f '$1'"; }
track_dir() { add_rollback_cmd "sudo rm -rf '$1'"; }
```
## 5. The Rollback Execution (`b rollback <tool>`)
Execution is line-by-line and fault-tolerant, allowing safe recovery even if a user injects a malformed command.
```bash
log_info "Rolling back..."
while IFS= read -r cmd; do
[ -z "$cmd" ] && continue
log_info "Executing: $cmd"
eval "$cmd" || log_warn "Failed to execute rollback step: $cmd"
done < "$BOOTSTRAP_UNINSTALLER_CMDS"
rm -f "$BOOTSTRAP_UNINSTALLER_CMDS"
log_success "Rollback complete."
```
## 6. Resilience Against User Modifications
Because `b ware <tool>` allows users to modify installation scripts:
1. **Dynamic Adaptation:** The manifest is built *during* execution, adapting to whatever packages the user manually added.
2. **Fault Isolation:** The `eval` loop ensures that a syntax error in one custom rollback step doesn't crash the removal of other tracked packages.
## 7. Handling Shared Dependencies
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.

View File

@@ -130,27 +130,20 @@ install_agy() {
cp "$extracted_binary" "$BINARY_PATH"
chmod +x "$BINARY_PATH"
rm -rf "$staging_dir"
track_file "$BINARY_PATH"
log_success "Antigravity CLI successfully installed to $BINARY_PATH."
}
configure_shell() {
# Ensure $TARGET_DIR is in PATH for shell configurations if not present
# Clean up legacy in-place configuration blocks
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
local path_content='export PATH="$HOME/.local/bin:$PATH"'
for config_file in "${target_files[@]}"; do
if [ -f "$config_file" ] && ! grep -q '\.local/bin' "$config_file" 2>/dev/null; then
log_info "Adding ~/.local/bin to PATH in $config_file..."
inject_block "$config_file" "local-bin path" "$path_content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
fi
remove_block "$config_file" "local-bin path"
done
write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"'
}
run_handoff() {

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Tool: asciicinema
# DisplayName: asciicinema
# Description: Install asciinema terminal recorder
#
# 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)"
cleanup() {
rm -rf "$TMP_DIR"
}
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)
fi
if [ -z "$latest_tag" ]; then
latest_tag="v3.2.1" # fallback
log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag"
else
log_info "Latest asciinema version found: $latest_tag"
fi
if has_command asciinema; then
local current_version
current_version=$(asciinema --version | head -n1 | awk '{print $2}')
if [[ "$current_version" != v* ]]; then
current_version="v${current_version}"
fi
if [[ "$current_version" == "$latest_tag" ]]; then
log_info "asciinema ${latest_tag} is already installed."
if ! confirm "Reinstall/Upgrade asciinema?"; then
log_info "Skipping asciinema installation."
return
fi
else
if ! confirm "Detecting asciinema ${current_version}. Upgrade to ${latest_tag}?"; then
log_info "Skipping asciinema installation."
return
fi
fi
else
if ! confirm "Install asciinema ${latest_tag}?"; then
log_info "Skipping asciinema installation."
return
fi
fi
# Detect architecture
local arch
arch=$(detect_arch)
local asciinema_arch=""
case "$arch" in
x86_64) asciinema_arch="x86_64-unknown-linux-gnu" ;;
arm64) asciinema_arch="aarch64-unknown-linux-gnu" ;;
*) 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}..."
curl -fsSL "$download_url" -o "$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"
# 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
track_file "/usr/local/bin/asciicinema"
log_success "asciinema ${latest_tag} installed."
}
main() {
install_asciicinema
echo
log_success "asciinema installation complete."
}
main "$@"

View File

@@ -67,6 +67,7 @@ install_bat() {
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."
@@ -75,31 +76,16 @@ install_bat() {
}
configure_shell() {
# Clean up legacy in-place configuration blocks
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
local content="alias cat='bat --paging=never -p'"
for config_file in "${target_files[@]}"; do
local target_file="$config_file"
if [ "$config_file" = "$HOME/.bashrc" ]; then
# Clean up old block from ~/.bashrc if present to avoid duplication
remove_block "$config_file" "bat alias"
target_file="$HOME/.bash_aliases"
# Ensure the file exists
if [ ! -f "$target_file" ]; then
touch "$target_file"
fi
fi
log_info "Adding bat alias to $target_file..."
inject_block "$target_file" "bat alias" "$content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
log_info "Sourcing $config_file..."
. "$config_file" 2>/dev/null || true
fi
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'"
}
main() {

View File

@@ -51,12 +51,18 @@ install_nvm() {
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
track_dir "$HOME/.nvm"
log_success "NVM source files successfully extracted to $HOME/.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'
@@ -66,16 +72,7 @@ export NVM_DIR="$HOME/.nvm"
EOF
)
for config_file in "${target_files[@]}"; do
log_info "Adding NVM configuration block to $config_file..."
inject_block "$config_file" "nvm setup" "$content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
log_info "Sourcing $config_file..."
. "$config_file" 2>/dev/null || true
fi
done
write_env_snippet "node" "$content"
}
install_node() {

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Tool: nvim
# DisplayName: Neovim
# Description: Install Neovim 0.11.7 and configuration
# Description: Install Neovim 0.12.0 and configuration
#
# Neovim Installer Script
#
@@ -14,7 +14,7 @@ fi
set -euo pipefail
NVIM_VERSION="0.11.7"
NVIM_VERSION="0.12.0"
NVIM_INSTALL_DIR="/opt/nvim"
NVIM_CONFIG_REPO="https://git.adityagupta.dev/sortedcord/editor.git"
NVIM_CONFIG_DIR="$HOME/.config/nvim"
@@ -44,6 +44,10 @@ install_packages() {
"fedora:gcc-c++"
create_fd_symlink
log_info "Installing tree-sitter-cli globally..."
sudo npm install -g tree-sitter-cli
add_rollback_cmd "sudo npm uninstall -g tree-sitter-cli"
}
install_nvim() {
@@ -83,6 +87,9 @@ install_nvim() {
sudo mv "$TMP_DIR/nvim-${nvim_arch}" "$NVIM_INSTALL_DIR"
sudo ln -sf "$NVIM_INSTALL_DIR/bin/nvim" /usr/local/bin/nvim
track_dir "$NVIM_INSTALL_DIR"
track_file "/usr/local/bin/nvim"
log_success "Installed:"
nvim --version | head -n1
@@ -99,28 +106,32 @@ install_config() {
log_info "Cloning configuration to $NVIM_CONFIG_DIR..."
git clone "$NVIM_CONFIG_REPO" "$NVIM_CONFIG_DIR"
track_dir "$NVIM_CONFIG_DIR"
log_success "Configuration installed."
}
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
local modified=false
if add_alias_if_missing "$config_file" "vim" "nvim"; then
modified=true
fi
if add_env_if_missing "$config_file" "EDITOR" "nvim"; then
modified=true
fi
# Source if modified (only for bashrc, and not when sourced to prevent recursion)
if [ "$modified" = true ] && [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
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"'
}
main() {

View File

@@ -173,13 +173,18 @@ install_pnpm() {
}
fi
track_dir "$HOME/.local/share/pnpm"
log_success "pnpm v${version} installed successfully!"
}
# ─── 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.
@@ -194,15 +199,7 @@ esac
EOF
)
for config_file in "${target_files[@]}"; do
log_info "Configuring pnpm in $config_file..."
inject_block "$config_file" "pnpm setup" "$content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
done
write_env_snippet "pnpm" "$content"
}
# ─── Main ─────────────────────────────────────────────────────────────

View File

@@ -14,6 +14,12 @@ fi
set -euo pipefail
TMP_DIR="$(make_temp_dir)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
# Ensure we have curl
install_downloader() {
if ! has_command curl; then
@@ -67,17 +73,10 @@ install_rust() {
local url="https://static.rust-lang.org/rustup/dist/${target}/rustup-init"
local tmpdir
tmpdir="$(make_temp_dir)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
local dest="$tmpdir/rustup-init"
local dest="$TMP_DIR/rustup-init"
log_info "Downloading rustup-init..."
curl -fsSL \"$url\" -o \"$dest\"| curl -fsSL \"$url\" -o \"$dest\"
curl -fsSL "$url" -o "$dest"
chmod +x "$dest"
@@ -86,25 +85,21 @@ install_rust() {
# -y: skip prompts (we already confirmed)
# --no-modify-path: let bootstrap manage the shell paths
"$dest" -y --no-modify-path
add_rollback_cmd "rustup self uninstall -y"
}
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
log_info "Configuring Rust environment in $config_file..."
local content='. "$HOME/.cargo/env"'
inject_block "$config_file" "rust init" "$content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
remove_block "$config_file" "rust init"
done
write_env_snippet "rust" '. "$HOME/.cargo/env"'
}
main() {

View File

@@ -71,29 +71,22 @@ install_starship() {
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"
}
configure_shell() {
# Add ~/.local/bin to PATH for the current process
export PATH="$HOME/.local/bin:$PATH"
local config_file="$HOME/.bashrc"
if [ -f "$config_file" ]; then
# Ensure ~/.local/bin is in PATH for this file if not already present
if ! grep -q '\.local/bin' "$config_file" 2>/dev/null; then
log_info "Adding ~/.local/bin to PATH in $config_file..."
local path_content='export PATH="$HOME/.local/bin:$PATH"'
inject_block "$config_file" "local-bin path" "$path_content"
fi
# 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
log_info "Adding starship initialization to $config_file..."
local content='eval "$(starship init bash)"'
inject_block "$config_file" "starship init" "$content"
# Source to apply changes in the current context
. "$config_file" 2>/dev/null || true
fi
write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"'
write_env_snippet "starship" 'eval "$(starship init bash)"'
}
main() {

View File

@@ -81,31 +81,23 @@ install_uv() {
cp "$TMP_DIR/uv" "$target_dir/uv"
cp "$TMP_DIR/uvx" "$target_dir/uvx"
chmod +x "$target_dir/uv" "$target_dir/uvx"
track_file "$target_dir/uv"
track_file "$target_dir/uvx"
}
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
# Ensure ~/.local/bin is in PATH for this file if not already present
if ! grep -q '\.local/bin' "$config_file" 2>/dev/null; then
log_info "Adding ~/.local/bin to PATH in $config_file..."
local path_content='export PATH="$HOME/.local/bin:$PATH"'
inject_block "$config_file" "local-bin path" "$path_content"
fi
log_info "Adding uv completion to $config_file..."
local content='eval "$(uv generate-shell-completion bash)"'
inject_block "$config_file" "uv completion" "$content"
# Source to apply changes in the current context
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
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)"'
}
main() {

View File

@@ -30,10 +30,10 @@ install_yay() {
fi
local needs_install=false
if ! pacman -Qq git &>/dev/null; then
if ! pkg_check git; then
needs_install=true
fi
if ! pacman -Qq base-devel &>/dev/null && ! pacman -Qg base-devel &>/dev/null; then
if ! pkg_check base-devel && ! pacman -Qg base-devel &>/dev/null; then
needs_install=true
fi
@@ -61,6 +61,7 @@ install_yay() {
log_info "Building and installing yay..."
makepkg -si
add_rollback_cmd "sudo pacman -R --noconfirm yay"
cd "$orig_dir"
log_info "Cleaning up installer directory..."

View File

@@ -21,7 +21,11 @@ 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'
@@ -37,16 +41,7 @@ y() {
EOF
)
for config_file in "${target_files[@]}"; do
log_info "Adding yazi wrapper function 'y' to $config_file..."
inject_block "$config_file" "yazi wrapper" "$wrapper_content"
done
# Source ~/.bashrc to make the alias immediately available in the current shell context (if sourced)
if [ -f "$HOME/.bashrc" ]; then
log_info "Sourcing ~/.bashrc..."
. "$HOME/.bashrc" 2>/dev/null || true
fi
write_alias_snippet "yazi" "$wrapper_content"
}
install_yazi() {
@@ -74,7 +69,7 @@ install_yazi() {
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/')
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
@@ -85,6 +80,7 @@ install_yazi() {
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 || \
@@ -106,6 +102,7 @@ install_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

View File

@@ -40,25 +40,21 @@ install_zoxide() {
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"
}
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
log_info "Adding zoxide initialization to $config_file..."
local content="eval \"\$(zoxide init --cmd cd bash)\""
inject_block "$config_file" "zoxide init" "$content"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
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)"'
}
main() {

View File

@@ -36,18 +36,9 @@ detect_arch() {
esac
}
# Install packages depending on detected distro
# Usage: pkg_install <package_name_arch> <package_name_debian> <package_name_fedora>
# Or simpler: map common packages to their distro equivalents
pkg_install() {
local distro
distro=$(detect_distro)
if [ "$distro" = "unknown" ]; then
log_error "Unsupported distribution. Cannot install packages automatically."
return 1
fi
_resolve_pkg_names() {
local distro="$1"
shift
local pkgs=()
for arg in "$@"; do
# Format can be "pkg" or "arch:pkg_a|debian:pkg_d|fedora:pkg_f"
@@ -69,27 +60,159 @@ pkg_install() {
pkgs+=("$arg")
fi
done
echo "${pkgs[@]}"
}
# Install packages depending on detected distro
# Usage: pkg_install <package_name_arch> <package_name_debian> <package_name_fedora>
# Or simpler: map common packages to their distro equivalents
pkg_install() {
local distro
distro=$(detect_distro)
if [ "$distro" = "unknown" ]; then
log_error "Unsupported distribution. Cannot install packages automatically."
return 1
fi
IFS=' ' read -ra pkgs <<< "$(_resolve_pkg_names "$distro" "$@")"
if [ ${#pkgs[@]} -eq 0 ]; then
return 0
fi
log_info "Installing packages via $distro package manager: ${pkgs[*]}"
local to_install=()
for pkg in "${pkgs[@]}"; do
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
return 0
fi
log_info "Installing packages via $distro package manager: ${to_install[*]}"
case "$distro" in
arch)
sudo pacman -Sy --needed --noconfirm "${pkgs[@]}"
sudo pacman -Sy --needed --noconfirm "${to_install[@]}"
;;
debian)
sudo apt update
sudo apt install -y "${pkgs[@]}"
sudo apt install -y "${to_install[@]}"
;;
fedora)
sudo dnf install -y "${pkgs[@]}"
sudo dnf install -y "${to_install[@]}"
;;
esac
}
# Check if packages are installed
# Returns 0 if all are installed, 1 otherwise
pkg_check() {
local distro
distro=$(detect_distro)
if [ "$distro" = "unknown" ]; then
return 1
fi
IFS=' ' read -ra pkgs <<< "$(_resolve_pkg_names "$distro" "$@")"
if [ ${#pkgs[@]} -eq 0 ]; then
return 0
fi
case "$distro" in
arch)
pacman -Qq "${pkgs[@]}" >/dev/null 2>&1
return $?
;;
debian)
dpkg -s "${pkgs[@]}" >/dev/null 2>&1
return $?
;;
fedora)
rpm -q "${pkgs[@]}" >/dev/null 2>&1
return $?
;;
esac
}
# Remove packages depending on detected distro
pkg_remove() {
local distro
distro=$(detect_distro)
if [ "$distro" = "unknown" ]; then
log_error "Unsupported distribution. Cannot remove packages automatically."
return 1
fi
IFS=' ' read -ra pkgs <<< "$(_resolve_pkg_names "$distro" "$@")"
if [ ${#pkgs[@]} -eq 0 ]; then
return 0
fi
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
fi
to_remove+=("$pkg")
done
if [ ${#to_remove[@]} -eq 0 ]; then
return 0
fi
log_info "Removing packages via $distro package manager: ${to_remove[*]}"
case "$distro" in
arch)
local pac_remove=()
for pkg in "${to_remove[@]}"; do
if pacman -Qq "$pkg" >/dev/null 2>&1; then
pac_remove+=("$pkg")
fi
done
if [ ${#pac_remove[@]} -gt 0 ]; then
sudo pacman -R --noconfirm "${pac_remove[@]}"
fi
;;
debian)
sudo apt remove -y "${to_remove[@]}"
;;
fedora)
sudo dnf remove -y "${to_remove[@]}"
;;
esac
}
# Export functions and variables for subshells
export _LIB_PLATFORM_SOURCED=1
export -f detect_distro detect_arch pkg_install
export -f detect_distro detect_arch _resolve_pkg_names pkg_install pkg_check pkg_remove

View File

@@ -2,9 +2,10 @@
declare -A INSTALLERS=(
[agy]="Antigravity CLI"
[asciicinema]="asciinema terminal recorder"
[bat]="Bat (alternative to cat) and configure alias"
[node]="Node.js (LTS) and NVM"
[nvim]="Neovim 0.11.7 and configuration"
[nvim]="Neovim 0.12.0 and configuration"
[pnpm]="pnpm package manager"
[rust]="Rustup and Rust compiler/toolchain"
[starship]="Starship shell prompt"
@@ -16,6 +17,7 @@ declare -A INSTALLERS=(
declare -A INSTALLER_DISPLAYS=(
[agy]="Antigravity"
[asciicinema]="asciicinema"
[bat]="Bat"
[node]="Node"
[nvim]="Neovim"
@@ -28,4 +30,4 @@ declare -A INSTALLER_DISPLAYS=(
[zoxide]="Zoxide"
)
INSTALLER_KEYS=(agy bat node nvim pnpm rust starship uv yay yazi zoxide)
INSTALLER_KEYS=(agy asciicinema bat node nvim pnpm rust starship uv yay yazi zoxide)

130
lib/rollback.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
if [ -n "${_LIB_ROLLBACK_SOURCED:-}" ]; then
return 0
fi
_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"
}
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"
touch "$BOOTSTRAP_UNINSTALLER_CMDS"
}
add_rollback_cmd() {
local cmd="$1"
if [ -n "${BOOTSTRAP_UNINSTALLER_CMDS:-}" ] && [ -f "$BOOTSTRAP_UNINSTALLER_CMDS" ]; then
# Prepend to the top of the file
sed -i "1i $cmd" "$BOOTSTRAP_UNINSTALLER_CMDS"
fi
}
track_file() {
add_rollback_cmd "sudo rm -f '$1'"
}
track_dir() {
add_rollback_cmd "sudo rm -rf '$1'"
}
create_savepoint() {
local name="$1"
echo "SAVEPOINT: $name" >> "$BOOTSTRAP_HISTORY_LOG"
log_success "Savepoint '$name' created."
}
mark_install_success() {
local tool="$1"
# Only record if we actually have an uninstaller
if [ -f "$BOOTSTRAP_UNINSTALLERS_DIR/${tool}.cmds" ]; then
echo "INSTALL: $tool" >> "$BOOTSTRAP_HISTORY_LOG"
fi
}
execute_rollback() {
local tool="$1"
local manifest="$BOOTSTRAP_UNINSTALLERS_DIR/${tool}.cmds"
if [ ! -f "$manifest" ]; then
log_warn "No rollback manifest found for '$tool'."
return 0
fi
export BOOTSTRAP_CURRENT_TOOL="$tool"
log_info "Rolling back '$tool'..."
while IFS= read -r cmd; do
[ -z "$cmd" ] && continue
log_info "Executing: $cmd"
eval "$cmd" || log_warn "Failed to execute: $cmd"
done < "$manifest"
rm -f "$manifest"
log_success "Rollback of '$tool' complete."
}
rollback_bare() {
if [ ! -s "$BOOTSTRAP_HISTORY_LOG" ]; then
log_info "No history available to rollback."
return 0
fi
local last_line
last_line=$(tail -n 1 "$BOOTSTRAP_HISTORY_LOG")
if [[ "$last_line" == INSTALL:* ]]; then
local tool="${last_line#INSTALL: }"
execute_rollback "$tool"
# Remove the last line efficiently
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
elif [[ "$last_line" == SAVEPOINT:* ]]; then
local sp="${last_line#SAVEPOINT: }"
log_warn "Last action was savepoint '$sp'. Cannot bare-rollback a savepoint."
fi
}
rollback_to_savepoint() {
local target_sp="$1"
if ! grep -q "SAVEPOINT: $target_sp" "$BOOTSTRAP_HISTORY_LOG"; then
log_error "Savepoint '$target_sp' not found in history."
return 1
fi
while [ -s "$BOOTSTRAP_HISTORY_LOG" ]; do
local last_line
last_line=$(tail -n 1 "$BOOTSTRAP_HISTORY_LOG")
if [[ "$last_line" == SAVEPOINT:\ $target_sp ]]; then
log_success "Reached savepoint '$target_sp'."
# Optionally remove the savepoint itself or keep it? Let's keep it.
break
elif [[ "$last_line" == INSTALL:* ]]; then
local tool="${last_line#INSTALL: }"
execute_rollback "$tool"
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
elif [[ "$last_line" == SAVEPOINT:* ]]; then
local sp="${last_line#SAVEPOINT: }"
log_info "Removing intermediate savepoint '$sp'..."
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
else
# Unknown line format, just remove it
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
fi
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

View File

@@ -11,9 +11,13 @@ if [ ! -d "$BOOTSTRAP_DIR/lib" ]; then
fi
export BOOTSTRAP_DIR
# Source common library
# Source libraries
if [ -f "$BOOTSTRAP_DIR/lib/common.sh" ]; then
. "$BOOTSTRAP_DIR/lib/common.sh"
. "$BOOTSTRAP_DIR/lib/rollback.sh"
. "$BOOTSTRAP_DIR/lib/platform.sh"
. "$BOOTSTRAP_DIR/lib/shell_config.sh"
init_rollback_system
else
echo "Error: Bootstrap libraries not found at $BOOTSTRAP_DIR/lib/" >&2
exit 1
@@ -109,9 +113,14 @@ run_ware() {
# Run the script (edited or unchanged)
log_info "Running ${display_name} installer..."
setup_uninstaller_context "$tool"
bash "$temp_script" "${cmd_args[@]}"
local run_status=$?
if [ "$run_status" -eq 0 ]; then
mark_install_success "$tool"
fi
# Cleanup
rm -f "$temp_script"
return "$run_status"
@@ -191,6 +200,24 @@ for script in "${SCRIPTS[@]}"; do
exit 1
fi
;;
fall)
local savepoint_name="${1:-}"
if [ -z "$savepoint_name" ]; then
log_error "Usage: b fall <savepoint_name>"
exit 1
fi
create_savepoint "$savepoint_name"
exit 0
;;
rb)
local target="${1:-}"
if [ -z "$target" ]; then
rollback_bare
else
rollback_to_savepoint "$target"
fi
exit 0
;;
*)
log_error "Unknown command '$script'."
log_info "Run 'b all' to list all available commands."

View File

@@ -102,6 +102,62 @@ add_env_if_missing() {
return 1 # Not added
}
# Write environment snippet to env.d/
# Usage: write_env_snippet <name> <content>
write_env_snippet() {
local name="$1"
local content="$2"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/env.d"
mkdir -p "$dir"
log_info "Writing environment snippet '$name' to $dir/${name}.sh"
echo "$content" > "$dir/${name}.sh"
if type add_rollback_cmd >/dev/null 2>&1; then
add_rollback_cmd "rm -f \"$dir/${name}.sh\""
fi
}
# Write alias snippet to aliases.d/
# Usage: write_alias_snippet <name> <content>
write_alias_snippet() {
local name="$1"
local content="$2"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/aliases.d"
mkdir -p "$dir"
log_info "Writing alias snippet '$name' to $dir/${name}.sh"
echo "$content" > "$dir/${name}.sh"
if type add_rollback_cmd >/dev/null 2>&1; then
add_rollback_cmd "rm -f \"$dir/${name}.sh\""
fi
}
# Remove environment snippet from env.d/
# Usage: remove_env_snippet <name>
remove_env_snippet() {
local name="$1"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/env.d"
if [ -f "$dir/${name}.sh" ]; then
log_info "Removing environment snippet '$name'"
rm -f "$dir/${name}.sh"
fi
}
# Remove alias snippet from aliases.d/
# Usage: remove_alias_snippet <name>
remove_alias_snippet() {
local name="$1"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/aliases.d"
if [ -f "$dir/${name}.sh" ]; then
log_info "Removing alias snippet '$name'"
rm -f "$dir/${name}.sh"
fi
}
# Setup fd symlink for Debian/Ubuntu (fdfind -> fd)
create_fd_symlink() {
if ! has_command fd && has_command fdfind; then
@@ -112,5 +168,6 @@ create_fd_symlink() {
# Export functions and variables for subshells
export _LIB_SHELL_CONFIG_SOURCED=1
export -f get_shell_configs remove_block inject_block add_alias_if_missing add_env_if_missing create_fd_symlink
export -f get_shell_configs remove_block inject_block add_alias_if_missing add_env_if_missing create_fd_symlink write_env_snippet write_alias_snippet remove_env_snippet remove_alias_snippet

View File

@@ -13,6 +13,7 @@ Here is a comparison of the size and complexity of using Bootstrap (`to b`) vers
| application | to b | not to b |
| :--- | :--- | :--- |
| **Antigravity CLI (`agy`)** | 197 lines | 239 lines (Official Antigravity install script) |
| **asciicinema (`asciicinema`)** | 99 lines | N/A (Official binary distribution) |
| **Bat (`bat`)** | 155 lines | N/A (Standard package install) |
| **Node.js & NVM (`node`)** | 156 lines | 507 lines (Official NVM install script) |
| **Neovim (`nvim`)** | 178 lines | N/A (Official binary/config distribution) |

View File

@@ -9,41 +9,3 @@ if [ -f "./scripts/generate_registry.sh" ]; then
./scripts/generate_registry.sh
git add lib/registry.sh
fi
VERSION_FILE="VERSION"
if [ ! -f "$VERSION_FILE" ]; then
echo "1.0.0" > "$VERSION_FILE"
git add "$VERSION_FILE"
exit 0
fi
# Check if there are staged changes other than VERSION, documentation (*.md), or installers folder
if ! git diff --cached --name-only | grep -Ev "^($VERSION_FILE$|.*\.md$|^installers/)" | grep -q .; then
# No other files staged, skip version bump
exit 0
fi
# Read current version
current_version=$(cat "$VERSION_FILE" | tr -d '[:space:]')
# Basic regex validation for semantic versioning (X.Y.Z)
if [[ ! "$current_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Current version '$current_version' in $VERSION_FILE is not in X.Y.Z format." >&2
exit 1
fi
# Parse version components
IFS='.' read -r major minor patch <<< "$current_version"
# Increment patch version
patch=$((patch + 1))
new_version="$major.$minor.$patch"
# Write to VERSION file
echo "$new_version" > "$VERSION_FILE"
# Add VERSION file to the commit
git add "$VERSION_FILE"
echo "[pre-commit] Automatically bumped version from $current_version to $new_version"