15 Commits

Author SHA1 Message Date
53e98c7542 release: v2.1.0 2026-06-24 23:34:12 +05:30
02d3c9241c feat: Resumable Download Helper and Manifest Preservation
- Route downloads through local cache directory
- Automatically resume interrupted downloads from the byte offset
- `setup_uninstaller_context` checks if a fail had happened. If yes then
  CLI preserves existing manifest instead of wiping it.
- Interruption Signal Traps and Prompts
2026-06-24 23:29:12 +05:30
c88839d3e0 docs: Update readme 2026-06-24 22:27:37 +05:30
c5e11891a8 feat(skills): Add Installer to use rollback and savepoint hooks 2026-06-24 22:26:21 +05:30
393868610f release: v2.0.0 2026-06-24 22:09:34 +05:30
368dea1bbd refactor: Update installer scripts to make use of rollback hooks and exec tracking 2026-06-24 22:04:30 +05:30
b31a326ca1 feat: Implement Rollbacks and Savepoints! 2026-06-24 22:01:30 +05:30
dc73804416 update yay installer to use package dep helpers 2026-06-24 20:38:53 +05:30
f118d66ec1 feat: package resolution,install,remove helpers 2026-06-24 20:21:06 +05:30
0486755771 refactor: use drop-ins for aliases and path management 2026-06-24 19:57:38 +05:30
725e3879d8 registry: New asciicinema installer 2026-06-24 19:06:29 +05:30
234112f304 Updated nvim to use v0.12 2026-06-24 17:14:07 +05:30
6fde048250 refactor: rust install script 2026-06-22 10:02:30 +05:30
9ce16a1f2b fix: route.sh to also source other lib files 2026-06-22 09:35:00 +05:30
57a11e16a3 fix: prevent pipefail in yazi 2026-06-22 09:21:12 +05:30
24 changed files with 886 additions and 279 deletions

View File

@@ -18,8 +18,9 @@ bootstrap/
├── installers/ # Individual installer scripts (install_<name>.sh)
├── lib/ # Shared libraries and router sourced by all installers
│ ├── common.sh # Logging, confirm(), has_command(), make_temp_dir()
│ ├── platform.sh # detect_distro(), detect_arch(), pkg_install()
│ ├── shell_config.sh # get_shell_configs(), inject_block(), remove_block(), add_alias_if_missing(), add_env_if_missing()
│ ├── platform.sh # detect_distro(), detect_arch(), pkg_install(), pkg_check(), pkg_remove()
│ ├── rollback.sh # Rollback tracking (track_file, track_dir, add_rollback_cmd)
│ ├── shell_config.sh # write_env_snippet, write_alias_snippet
│ ├── registry.sh # Dynamically generated installer registry
│ └── routes.sh # Central router script
├── commands/ # Non-installer commands (help, con, uninstall)
@@ -55,7 +56,15 @@ At the top of your new installer script, right below `#!/usr/bin/env bash`, add
The central router `lib/routes.sh` and autocomplete function in `b.sh` will dynamically parse this metadata from all `install_*.sh` scripts to register the installer and keys automatically! No manual edits to `lib/routes.sh` or `b.sh` are required.
### Step 3: Verify (optional)
### Step 3: Implement Rollback Tracking (Crucial)
To ensure the user can seamlessly use `b rb <name>`, all manual modifications must be tracked:
- When extracting binaries to `~/.local/bin/`, use `track_file "$HOME/.local/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.
### Step 4: Verify (optional)
Verify that the installer works and appears in the help output:
- Run `b all` to confirm it appears in the help list.
@@ -101,35 +110,23 @@ install_<name>() {
fi
# --- Tool-specific installation logic goes here ---
# Use pkg_install for distro packages:
# pkg_install <package>
# Use detect_distro for distro-specific logic:
# local distro; distro=$(detect_distro)
# Use detect_arch for arch-specific logic:
# local arch; arch=$(detect_arch)
# For GitHub releases, use curl pattern (see bat installer for reference)
# Use pkg_install for distro packages (it automatically handles rollback hooks!):
# pkg_install "arch:<pkg_a>|debian:<pkg_d>|fedora:<pkg_f>"
# 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!
}
# ─── Shell Configuration (if needed) ─────────────────────────────────
configure_shell() {
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
for config_file in "${target_files[@]}"; do
log_info "Configuring <ToolName> in $config_file..."
# Use inject_block to add shell init/aliases/env vars:
# inject_block "$config_file" "<name> init" "<content>"
# Use add_alias_if_missing for simple aliases:
# add_alias_if_missing "$config_file" "<alias>" "<value>"
# Use add_env_if_missing for environment variables:
# add_env_if_missing "$config_file" "VAR_NAME" "value"
# Source if modified (only for bashrc)
if [ "$config_file" = "$HOME/.bashrc" ]; then
. "$config_file" 2>/dev/null || true
fi
done
# Use drop-in snippets for shell configuration (they auto-rollback)
# write_env_snippet "<name>" "export VAR_NAME=value\neval \"\$(<name> init bash)\""
# write_alias_snippet "<name>" "alias <name>='<command>'"
:
}
# ─── Main ─────────────────────────────────────────────────────────────
@@ -162,6 +159,7 @@ These are pre-loaded by `bootstrap.sh` — no need to source them manually in in
| `confirm "prompt"` | Interactive yes/no prompt, returns 0 for yes |
| `has_command <cmd>` | Check if a command exists (returns 0/1) |
| `make_temp_dir` | Create and echo a temp directory path |
| `download_file <url> <dest>` | Resumable and cached download of `<url>` to `<dest>`. Uses `~/.local/state/bootstrap/cache/`. |
### From `lib/platform.sh`
@@ -169,18 +167,23 @@ These are pre-loaded by `bootstrap.sh` — no need to source them manually in in
|---|---|
| `detect_distro` | Echoes `arch`, `debian`, `fedora`, or `unknown` |
| `detect_arch` | Echoes `x86_64` or `arm64` |
| `pkg_install <pkg>...` | Install packages via the system package manager. Supports distro-specific mapping: `"arch:pkg_a\|debian:pkg_d\|fedora:pkg_f"` |
| `pkg_install <pkg>...` | Install packages. Supports distro mapping: `"arch:pkg_a\|debian:pkg_d\|fedora:pkg_f"`. Automatically integrates with rollback context and handles package reference counting. |
| `pkg_check <pkg>...` | Returns 0 if packages are installed. Supports identical mapping syntax. |
### From `lib/rollback.sh`
| Function | Description |
|---|---|
| `track_file <path>` | Registers a file for deletion during `b rb` rollback. |
| `track_dir <path>` | Registers a directory for recursive deletion during rollback. |
| `add_rollback_cmd <cmd>` | Adds a raw bash command to the uninstall manifest (e.g., `add_rollback_cmd "sudo npm uninstall -g <pkg>"`). |
### From `lib/shell_config.sh`
| Function | Description |
|---|---|
| `get_shell_configs` | Space-separated list of existing RC files (`~/.bashrc`) |
| `inject_block <file> <name> <content>` | Idempotently inject a named block into a config file (removes old block first) |
| `remove_block <file> <name>` | Remove a named block from a config file |
| `add_alias_if_missing <file> <alias> <value>` | Add an alias line if not already present |
| `add_env_if_missing <file> <var> <value>` | Add an `export VAR="value"` line if not already present |
| `create_fd_symlink` | Symlink `fdfind``fd` on Debian/Ubuntu |
| `write_env_snippet <name> <content>` | Creates an isolated `env.d/` shell drop-in snippet and registers it for rollback. |
| `write_alias_snippet <name> <content>` | Creates an isolated `aliases.d/` shell drop-in snippet and registers it for rollback. |
---
@@ -194,27 +197,10 @@ cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
```
### Distro-specific installation (e.g., GitHub .deb for Debian, pacman for Arch)
### Distro-specific mapping
```bash
local distro
distro=$(detect_distro)
case "$distro" in
arch)
pkg_install <package>
;;
debian)
# Download .deb from GitHub releases
;;
fedora)
pkg_install <package>
;;
*)
log_error "Unsupported distribution."
exit 1
;;
esac
pkg_install "arch:neovim|debian:nvim|fedora:neovim" "curl" "git"
```
### Fetching latest GitHub release tag
@@ -233,11 +219,19 @@ if [ -z "$latest_tag" ]; then
fi
```
### Shell block injection (idempotent)
### Resumable Download and Extraction
```bash
# Block name should be unique and descriptive
inject_block "$config_file" "<tool> init" 'eval "$(tool init bash)"'
local url="https://github.com/owner/repo/releases/download/${version}/archive.tar.gz"
local dest="$TMP_DIR/archive.tar.gz"
# Resumable, cached download
download_file "$url" "$dest"
# Extract and install
tar -xzf "$dest" -C "$TMP_DIR"
sudo cp "$TMP_DIR/binary" /usr/local/bin/binary
track_file "/usr/local/bin/binary"
```
---
@@ -245,11 +239,10 @@ inject_block "$config_file" "<tool> init" 'eval "$(tool init bash)"'
## Rules & Conventions
1. **File naming**: Always `install_<name>.sh` in the `installers/` directory.
2. **Registry generation**: The registry in `lib/registry.sh` is automatically generated by `scripts/generate_registry.sh` (run automatically on commit by the git pre-commit hook).
3. **Confirmation prompts**: Always ask before installing. Check if already installed first.
4. **Idempotent**: Installers must be safe to re-run. Use `inject_block` (not append) for shell configs.
2. **Confirmation prompts**: Always ask before installing. Check if already installed first.
3. **Rollback Tracking**: NEVER omit rollback hooks. If you move a file to `~/.local/bin/`, you MUST call `track_file`. If you run `makepkg`, you MUST call `add_rollback_cmd` for `pacman -R`.
4. **Shell Drop-ins**: Always use `write_env_snippet` or `write_alias_snippet` instead of manually injecting code directly into `~/.bashrc`.
5. **No hardcoded paths**: Use `$HOME`, library functions, and `detect_*` helpers.
6. **Error handling**: Use `set -euo pipefail` after the guard block.
7. **CLI Enforcement Guard**: Always copy the standalone execution guard block verbatim to the top of your installer script to prevent direct execution.
8. **`main "$@"`**: Always end with this pattern to pass through CLI arguments.
9. **Clean Official Scripts**: When implementing official curl/install scripts provided in the prompt, strip them of bloat, macOS/Windows support, and redundant shell setups before writing the script.
8. **Clean Official Scripts**: When implementing official curl/install scripts provided in the prompt, strip them of bloat, macOS/Windows support, and redundant shell setups before writing the script.

View File

@@ -1 +1 @@
1.2.1
2.1.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

133
docs/rollback_design.md Normal file
View File

@@ -0,0 +1,133 @@
# 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.
## 8. Fault Tolerance, Resumability, and Interrupted Installations
To handle failures during installation (e.g., network drops, script errors, or user cancellation via `Ctrl+C`), the CLI incorporates a transactional approach that balances **automatic rollback** and **resumability**:
### A. The Interruption Trap & Prompt
When running an installer, the central router (`lib/routes.sh`) traps `SIGINT` and `SIGTERM` signals. If the installation fails or is interrupted:
1. The trap catches the event and stops execution.
2. The user is prompted interactively:
- **Rollback (r)**: Invokes `execute_rollback <tool>` immediately to clean up all partial modifications.
- **Keep (k)**: Preserves the partial changes and leaves the `.cmds` manifest intact.
3. In non-interactive environments (e.g., CI/CD or scripts), the CLI defaults to **automatic rollback** to keep the system clean.
### B. Resuming via Preserved Manifests
If the user chooses to **keep** the partial state and runs `b <tool>` again:
1. `setup_uninstaller_context` detects that a manifest already exists and that the tool was *not* successfully installed (no `INSTALL: <tool>` in the history log).
2. It **preserves** the existing manifest instead of wiping it.
3. As the script runs again from the top, new rollback commands are prepended to the existing manifest, maintaining the correct LIFO order without losing the tracking of previously completed steps.
### C. Resumable Downloads (Caching Layer)
To make rerunning an interrupted script fast and efficient, installers use `download_file <url> <dest>` instead of raw `curl`:
1. It downloads the payload to a central cache directory: `~/.local/state/bootstrap/cache/`.
2. It uses `curl -C -` to continue the download from the byte offset where it was interrupted.
3. Once completed, it copies the cached file to the installer's temp directory.
4. Distro package manager commands (`pkg_install`) and shell snippets (`write_env_snippet`) are naturally idempotent, allowing the script to breeze through already completed steps in milliseconds and resume exactly where the heavy work failed.

View File

@@ -96,7 +96,7 @@ install_agy() {
fi
log_info "Downloading release package..."
curl -fsSL "$url" -o "$staging_payload"
download_file "$url" "$staging_payload"
# Verify SHA512 Checksum
local actual_hash
@@ -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}..."
download_file "$download_url" "$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

@@ -63,10 +63,11 @@ install_bat() {
local deb_url="https://github.com/sharkdp/bat/releases/download/${latest_tag}/bat_${version}_${deb_arch}.deb"
log_info "Downloading Bat from ${deb_url}..."
curl -fsSL "$deb_url" -o "$TMP_DIR/bat.deb"
download_file "$deb_url" "$TMP_DIR/bat.deb"
log_info "Installing Bat package..."
sudo apt install -y "$TMP_DIR/bat.deb"
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

@@ -45,18 +45,23 @@ install_nvm() {
local nvm_url="https://github.com/nvm-sh/nvm/archive/refs/tags/${latest_tag}.tar.gz"
log_info "Downloading NVM from $nvm_url..."
curl -fsSL "$nvm_url" -o "$TMP_DIR/nvm.tar.gz"
download_file "$nvm_url" "$TMP_DIR/nvm.tar.gz"
log_info "Extracting NVM archive directly to $HOME/.nvm (stripping versioned subfolder to keep config generic)..."
mkdir -p "$HOME/.nvm"
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 +71,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() {
@@ -75,7 +79,7 @@ install_nvim() {
local nvim_url="https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-${nvim_arch}.tar.gz"
log_info "Downloading Neovim v${NVIM_VERSION} for ${arch}..."
curl -fsSL "$nvim_url" -o "$TMP_DIR/nvim.tar.gz"
download_file "$nvim_url" "$TMP_DIR/nvim.tar.gz"
tar -xzf "$TMP_DIR/nvim.tar.gz" -C "$TMP_DIR"
@@ -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

@@ -34,7 +34,11 @@ trap cleanup EXIT
# ─── Helper Functions ─────────────────────────────────────────────────
download() {
if [ -n "${2:-}" ]; then
download_file "$1" "$2"
else
curl -fsSL "$1"
fi
}
is_glibc_compatible() {
@@ -147,7 +151,7 @@ install_pnpm() {
if [ "$major_version" -ge 11 ]; then
# v11+: distributed as tarballs containing the binary and dist/ directory
download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}.tar.gz" > "$TMP_DIR/pnpm.tar.gz" || {
download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}.tar.gz" "$TMP_DIR/pnpm.tar.gz" || {
log_error "Failed to download pnpm tarball."
return 1
}
@@ -162,7 +166,7 @@ install_pnpm() {
}
else
# Older versions: distributed as a single executable binary
download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}" > "$TMP_DIR/pnpm" || {
download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}" "$TMP_DIR/pnpm" || {
log_error "Failed to download pnpm binary."
return 1
}
@@ -173,13 +177,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 +203,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\"
download_file "$url" "$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

@@ -59,7 +59,7 @@ install_starship() {
log_info "Downloading Starship from ${download_url}..."
local archive="$TMP_DIR/starship.tar.gz"
curl -fsSL "$download_url" -o "$archive"
download_file "$download_url" "$archive"
# Extract the binary
log_info "Extracting Starship binary..."
@@ -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

@@ -68,7 +68,7 @@ install_uv() {
log_info "Downloading uv from ${download_url}..."
local archive="$TMP_DIR/uv.tar.gz"
curl -fsSL "$download_url" -o "$archive"
download_file "$download_url" "$archive"
# Extract the binaries
log_info "Extracting uv binaries..."
@@ -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,17 +69,18 @@ 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
local deb_url="https://github.com/sxyazi/yazi/releases/download/${latest_tag}/yazi-x86_64-unknown-linux-gnu.deb"
log_info "Downloading Yazi ${latest_tag} from ${deb_url}..."
curl -fsSL "$deb_url" -o "$TMP_DIR/yazi.deb"
download_file "$deb_url" "$TMP_DIR/yazi.deb"
log_info "Installing Yazi package..."
sudo apt install -y "$TMP_DIR/yazi.deb"
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

@@ -84,9 +84,46 @@ version_lt() {
done
return 1
}
# Cached and resumable download helper
download_file() {
local url="$1"
local dest="$2"
local cache_dir="$HOME/.local/state/bootstrap/cache"
mkdir -p "$cache_dir"
local safe_name
if has_command md5sum; then
safe_name=$(echo -n "$url" | md5sum | cut -d' ' -f1)
elif has_command shasum; then
safe_name=$(echo -n "$url" | shasum | cut -d' ' -f1)
else
safe_name=$(echo -n "$url" | tr -c '[:alnum:]_.-' '_')
fi
local base_name
base_name=$(basename "$url")
local cache_file="$cache_dir/${safe_name}_${base_name}"
log_info "Downloading $base_name (resumable)..."
if ! curl -fL -C - "$url" -o "$cache_file"; then
local exit_code=$?
# Exit code 33: HTTP server doesn't support ranges/resuming
# Exit code 36: Bad download resume offset
if [ $exit_code -eq 33 ] || [ $exit_code -eq 36 ]; then
log_warn "Server does not support resuming. Retrying from scratch..."
rm -f "$cache_file"
curl -fL "$url" -o "$cache_file" || return 1
else
return $exit_code
fi
fi
mkdir -p "$(dirname "$dest")"
cp "$cache_file" "$dest"
}
# Export functions and variables for subshells
export _LIB_COMMON_SOURCED=1
export RED GREEN YELLOW BLUE NC
export -f require_bash log_info log_success log_warn log_error confirm has_command make_temp_dir version_lt
export -f require_bash log_info log_success log_warn log_error confirm has_command make_temp_dir version_lt download_file

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)

138
lib/rollback.sh Normal file
View File

@@ -0,0 +1,138 @@
#!/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"
# If a manifest already exists and the tool is NOT marked as successfully installed
# in history.log, we treat this as a resumed run. We preserve the manifest so
# that new commands are prepended to the existing ones.
if [ -f "$BOOTSTRAP_UNINSTALLER_CMDS" ] && ! grep -q "^INSTALL: $tool$" "$BOOTSTRAP_HISTORY_LOG" 2>/dev/null; then
log_info "Resuming installation of '$tool'. Preserving existing rollback manifest."
else
# Fresh installation or reinstall, start with a clean slate
rm -f "$BOOTSTRAP_UNINSTALLER_CMDS"
fi
touch "$BOOTSTRAP_UNINSTALLER_CMDS"
}
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,57 @@ run_ware() {
# Run the script (edited or unchanged)
log_info "Running ${display_name} installer..."
setup_uninstaller_context "$tool"
# Set trap for signals to intercept interruption and allow user to choose rollback/keep
local interrupted=false
trap 'interrupted=true' INT TERM
bash "$temp_script" "${cmd_args[@]}"
local run_status=$?
# Restore default traps
trap - INT TERM
if [ "$run_status" -eq 0 ] && [ "$interrupted" = "false" ]; then
mark_install_success "$tool"
else
echo
if [ "$interrupted" = "true" ]; then
log_error "Installation of ${display_name} was interrupted."
run_status=130
else
log_error "Installation of ${display_name} failed with exit code $run_status."
fi
local choice=""
if [ -t 0 ]; then
while true; do
read -r -p "Would you like to [r]ollback partial changes, or [k]eep them to resume/debug later? (r/k): " choice </dev/tty || choice="r"
case "$choice" in
[Rr]*)
execute_rollback "$tool"
run_status=1
break
;;
[Kk]*)
log_info "Keeping partial changes. Run 'b ${tool}' again to resume."
run_status=1
break
;;
*)
echo "Invalid choice. Please enter 'r' or 'k'."
;;
esac
done
else
# Non-interactive environment, default to safe rollback
log_warn "Non-interactive shell detected. Defaulting to automatic rollback to keep system clean."
execute_rollback "$tool"
run_status=1
fi
fi
# Cleanup
rm -f "$temp_script"
return "$run_status"
@@ -191,6 +243,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) |
@@ -76,6 +77,28 @@ b con i3
It automatically fuzzy-finds the folder in case there is no exact match. Also, in case there is only a singular config file in that folder, then it will directly open that file.
### Rollbacks and Savepoints (`b rb` and `b fall`)
Bootstrap CLI features a powerful, procedural rollback system. It strictly tracks every extracted binary, configuration snippet, and package manager transaction to ensure your environment stays clean.
To safely uninstall the very last tool you installed (including wiping its shell paths and aliases):
```bash
b rb
```
To create a named savepoint before experimenting with your setup:
```bash
b fall pre_dev_setup
```
To completely roll back all installations made after that savepoint, restoring your system back to that exact state:
```bash
b rb pre_dev_setup
```
### Updating
To check for updates and update the tool manually:

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"