44 Commits

Author SHA1 Message Date
83c524441c fix #19: Prevent Checkpoint from having same bug as
Some checks failed
Lint / lint (push) Failing after 14s
Lint / lint (pull_request) Failing after 13s
2026-06-27 00:39:33 +05:30
cee345e3f0 feat: Unified Uninstallation and Rollback
- Removed `BOOTSTRAP_PACKAGES_DIR`
b rb <tool> can now uninstall that particular tool
2026-06-27 00:20:56 +05:30
0c16640593 refactor: Simplified pkg_install and pkg_remove
`pkg_install` and `pkg_remove` are just wrappers that map aliases and
directly invooke the system package manager.

- They do not manage state or inject rollback commands
2026-06-27 00:19:14 +05:30
d5c90d6e85 refactor: Use XDG compliant isolated directory structure
Some checks failed
Lint / lint (push) Failing after 21s
Lint / lint (pull_request) Failing after 16s
- Using $BOOTSTRAP_BIN, $BOOTSTRAP_OPT, etc
- Add defensive fallback for undefined vars in common.sh
2026-06-26 23:52:03 +05:30
4c1c7de0b7 refactor: Remove redundant curl availability checks
Some checks failed
Lint / lint (push) Failing after 56s
Lint / lint (pull_request) Failing after 1m5s
2026-06-26 21:49:24 +05:30
29de051b7d refactor: Remove standalone exec prevention code blocks from installers
Some checks failed
Lint / lint (push) Failing after 16s
Lint / lint (pull_request) Failing after 35s
2026-06-26 21:40:48 +05:30
a4e5bc1175 refactor: Remove legacy backwards compat code
Some checks failed
Lint / lint (push) Failing after 37s
Lint / lint (pull_request) Failing after 47s
Remove the configure shell code blocks
2026-06-26 21:37:11 +05:30
36c7be07b3 refactor: Migrate all installers to use installation strategies 2026-06-26 21:35:34 +05:30
0eaea2c997 refactor: Unify fragmented install strategies within installers
Some checks failed
Lint / lint (push) Failing after 33s
Lint / lint (pull_request) Failing after 17s
bat and yazi installers use latest binary releases from github over
package manager for arch and fedora
2026-06-26 20:19:19 +05:30
4eec27570e refactor: Installers use github_get_latest_release helpers
Some checks failed
Lint / lint (push) Failing after 14s
Lint / lint (pull_request) Failing after 13s
2026-06-26 20:11:54 +05:30
f5a266ff70 feat: Implement the github release helper with github.sh 2026-06-26 19:57:47 +05:30
c42687a710 feat: Added registry helpers for installers
Some checks failed
Lint / lint (push) Failing after 13s
Lint / lint (pull_request) Failing after 13s
2026-06-26 18:36:39 +05:30
7f3ff45f05 refactor: Use jq instead of custom posix complient json.sh
Some checks failed
Lint / lint (push) Failing after 14s
Lint / lint (pull_request) Failing after 15s
While json.sh worked decently for reading json files, I didn't want to
implement writing to json files as well and make it completely
unreadable due to the added complexity.

So, I think its better to just use jq and keep things relatively simple
with the tradeoff of a lightweight dependency
2026-06-26 18:19:23 +05:30
780e79364f fix #9: Add validation check for pkg_remove
Some checks failed
Lint / lint (push) Failing after 3m14s
Lint / lint (pull_request) Failing after 17s
2026-06-25 22:40:13 +05:30
f158c4e913 feat(installers): Added lazygit installer 2026-06-25 22:21:26 +05:30
fc4303bc99 chore: Bumpt plugin supported version 2026-06-25 22:08:37 +05:30
6b0d07d70a ci: Added linting workflow using shellcheck 2026-06-25 22:03:50 +05:30
f8f41e4295 release: v2.2.0 2026-06-25 21:48:46 +05:30
a254001da8 feat(plugins): decouple runtime cache and add dynamic auto-generation 2026-06-25 21:48:28 +05:30
9a7404a65f feat(plugins): Added the official bootstrap plugin repository 2026-06-25 21:46:30 +05:30
b697fc5bba fix: Prevent unbounded parallel loop in multi file downloader 2026-06-25 21:26:34 +05:30
355588c7f9 refactor: Added new download_multiple_files_parallel helper function
- plugin.sh uses this to download manifests concurrently
2026-06-25 21:16:28 +05:30
d108f14ce5 refactor: Run plugin directly in memory in ephemeral mode 2026-06-25 19:41:50 +05:30
62a4759724 feat: Added ephemeral support for plugins 2026-06-25 19:38:01 +05:30
fdb2e108ee docs: Added plugins and development guide 2026-06-25 19:26:51 +05:30
9c86486ee6 feat: Added support for lazy loading plugins 2026-06-25 19:20:09 +05:30
33b98477bf feat: Added a custom json extractor 2026-06-25 19:18:13 +05:30
b813061e9a feat(installers): Added docker installer 2026-06-25 10:32:06 +05:30
7fe9ac913b refactor: bashrc is always sourced after tool install automatically
- Added source_bashrc funciton in shell_confi.sh
- Removed bashrc source commands from installer scripts
- Updated skill instructions
2026-06-25 09:53:03 +05:30
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
44 changed files with 2320 additions and 582 deletions

5
.agents/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
# Bootstrap Project Rules
## Repository Cleanliness & Runtime Separation
- **No Repository Clutter**: Do not commit, track, or create runtime configuration, cache, or temporary files in the repository root.
- **Dynamic Initialization**: All runtime-generated files (such as `plugin_sources.txt`, `lib/plugin_cache.sh`, or local plugin downloads) must reside strictly under the user's active `$BOOTSTRAP_DIR` (e.g., `~/.config/bootstrap`). The CLI must auto-generate or initialize these files dynamically at runtime if they are missing, ensuring a zero-configuration out-of-the-box experience.

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 "${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.
### 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" "${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) ─────────────────────────────────
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,34 +197,16 @@ 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" "git"
```
### Fetching latest GitHub release tag
```bash
local latest_tag=""
if has_command curl; then
latest_tag=$(curl -sL https://api.github.com/repos/<owner>/<repo>/releases/latest \
| grep '"tag_name":' | head -n1 \
| sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true)
@@ -233,11 +218,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 +238,11 @@ 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.
9. **No manual shell re-sourcing**: Do NOT manually run `source ~/.bashrc` or print instructions asking the user to run it. Sourcing of the shell configuration is handled automatically by the central router and CLI at the end of the installation.

View File

@@ -25,12 +25,22 @@ When the user asks to "cut a release", "bump the version", or "tag a new version
- If any core-CLI commit has `feat:` → **minor**
- Otherwise (only `fix:`, `refactor:`, etc. in core-CLI) → **patch**
- If *all* commits are installer-only or docs-only → inform the user no release is needed.
3. **Run the release script** non-interactively (if an actual bump is needed):
```bash
./scripts/release.sh --<level> -y
3. **Formulate a verbose, structured description of the changes** based on the analyzed commits. Group the changes into logical sections (such as "Breaking Changes & Major Features:" and "Other Updates:") and list the corresponding commit messages or summaries. Example format:
```text
Breaking Changes & Major Features:
feat: Resumable Download Helper and Manifest Preservation
Other Updates:
docs: Update readme
feat(skills): Add Installer to use rollback and savepoint hooks
```
(e.g., `./scripts/release.sh --minor -y`)
4. **Push** the tag and commit (ask for user confirmation before pushing):
4. **Run the release script** non-interactively, passing the compiled description:
```bash
./scripts/release.sh --<level> -y -m "<verbose description>"
```
5. **Push** the tag and commit (ask for user confirmation before pushing):
```bash
git push origin master <tag>
```

37
.gitea/workflows/lint.yml Normal file
View File

@@ -0,0 +1,37 @@
name: Lint
on:
push:
branches-ignore:
- main
- master
paths-ignore:
- '**.md'
- '.gitignore'
- 'docs/**'
pull_request:
branches:
- master
paths-ignore:
- '**.md'
- '.gitignore'
- 'docs/**'
jobs:
lint:
runs-on: production
steps:
- name: Install Dependencies
run: |
apt-get update
apt-get install -y git shellcheck
- name: Checkout Code
run: |
git config --global --add safe.directory '*'
rm -rf * .git || true
git clone --depth 1 $GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git .
- name: Run ShellCheck
run: |
find . -type f -name '*.sh' | xargs shellcheck

31
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Lint
on:
push:
branches:
- main
- master
paths-ignore:
- '**.md'
- '.gitignore'
- 'docs/**'
pull_request:
branches:
- main
- master
paths-ignore:
- '**.md'
- '.gitignore'
- 'docs/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Run ShellCheck
run: |
sudo apt-get update && sudo apt-get install -y shellcheck
find . -type f -name '*.sh' | xargs shellcheck

View File

@@ -1 +1 @@
1.2.1
2.2.0

9
b.sh
View File

@@ -66,6 +66,15 @@ b() {
# Execute the routes file
bash "$routes_file" "$@"
local ret=$?
# Sourced again in the parent shell after successfully running the command
if [ $ret -eq 0 ]; then
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
return $ret
}
# Autocompletion for the b command in Bash

View File

@@ -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/platform.sh" "lib/shell_config.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
@@ -53,8 +48,12 @@ 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"
. "$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
exit 1
@@ -66,7 +65,26 @@ 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=(
@@ -75,14 +93,23 @@ install_bootstrap() {
"lib/routes.sh"
"lib/registry.sh"
"lib/common.sh"
"lib/rollback.sh"
"lib/platform.sh"
"lib/shell_config.sh"
"lib/registry_helpers.sh"
"lib/github.sh"
"lib/plugins.sh"
"commands/help.sh"
"commands/con.sh"
"commands/uninstall.sh"
"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
@@ -126,13 +153,22 @@ 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"
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
# <<< bootstrap-cli setup <<<
EOF

View File

@@ -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

View File

@@ -48,6 +48,13 @@ if version_lt "$local_ver" "$remote_ver" || [ "$force_update" = true ]; then
if bash "$tmp_bootstrap"; then
# Update the last update timestamp
date +%s > "${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/.last_b_update" 2>/dev/null || true
# Update plugin cache
if [ -f "${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/lib/plugins.sh" ]; then
. "${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/lib/plugins.sh"
update_plugin_cache
fi
log_success "Bootstrap CLI successfully updated to version $remote_ver!"
else
log_error "Failed to execute bootstrap installer."

162
docs/client_spec_auth.md Normal file
View File

@@ -0,0 +1,162 @@
# Client Authentication and Provisioning Specification
This document defines the interface and protocol flow for the client application interacting with the bootstrap authentication server. It serves as a guide for implementing any client, particularly the dual mode bash script client.
## Core Concepts
The bootstrapping process establishes trust between a new client device, an existing administrator device, and the authentication server. The authentication server uses Ed25519 public key cryptography for authentication and `age` for secure payload encryption.
### Dual Mode Client
The client application operates in one of two modes:
1. **Requester Mode**: Used by a new, unprovisioned device to request access and receive secrets.
2. **Approver Mode**: Used by an already provisioned administrator device to authorize pending requests.
---
## The Authentication and Provisioning Flow
The complete flow consists of five sequential phases.
### Phase 1: Administrator Bootstrapping
1. The server starts up. If the `ADMIN_PUBLIC_KEY` environment variable is set, the server automatically registers and approves this public key as an administrator device in the database.
2. The administrator client on the admin machine is configured to use the corresponding private key.
### Phase 2: Client Request Initiation (Requester Mode)
1. The new device runs the client in requester mode:
```bash
b me (optional: --server <server_url>) [default auth server is https://b.adityagupta.dev/auth]
```
2. The client script generates an Ed25519 key pair locally.
3. The client sends a `POST /api/register` request containing its generated public key.
4. The server registers the device in a `pending` state and returns:
- A short, human readable `user_code` (e.g. 4 to 8 characters).
- A unique `device_id`.
5. The client script displays the `user_code` to the operator and begins polling the challenge endpoint:
```
GET /api/challenge/poll?device_id=<device_id>
```
### Phase 3: Administrator Approval (Approver Mode)
1. The operator reads the `user_code` from the requesting device's terminal.
2. On the administrator device, the operator runs the client in approver mode:
```bash
b trust <user_code> [--server <server_url>]
```
3. The administrator client queries the server to retrieve the pending registration details:
```
GET /api/pending/<user_code>
```
4. The server returns the pending `device_id` and the requester's `public_key`.
5. The administrator client displays these details. The operator confirms the request.
6. The administrator client signs a payload containing the requester's `device_id` and the approval action using its Ed25519 administrator private key.
7. The administrator client submits the signature and its public key to the server:
```
POST /api/approve
```
8. The server verifies the administrator's signature against the registered administrator public keys. If valid, the server transitions the requester device state from `pending` to `approved`.
### Phase 4: Payload Provisioning
1. Once the requester device is approved, the server generates the secret payload (or retrieves it from a secure source).
2. The server encrypts the payload using the requester's public key via `age`.
3. The server stores this encrypted payload as a challenge response.
### Phase 5: Challenge Completion and Retrieval
1. The next poll from the requester client to `GET /api/challenge/poll?device_id=<device_id>` succeeds.
2. The server returns the `age` encrypted payload.
3. The requester client decrypts the payload using its local private key.
4. The secrets are successfully provisioned.
---
## API Endpoints Reference
### 1. Register Device
- **Endpoint**: `POST /api/register`
- **Request Body**:
```json
{
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5..."
}
```
- **Response Body (200 OK)**:
```json
{
"device_id": "uuid-string",
"user_code": "A3F9K2"
}
```
### 2. Get Pending Device Details
- **Endpoint**: `GET /api/pending/<user_code>`
- **Response Body (200 OK)**:
```json
{
"device_id": "uuid-string",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5..."
}
```
### 3. Approve Device
- **Endpoint**: `POST /api/approve`
- **Request Body**:
```json
{
"device_id": "uuid-string",
"admin_public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...",
"signature": "hex-encoded-signature-bytes"
}
```
- **Response Body (200 OK)**: Empty or success confirmation.
### 4. Poll Challenge
- **Endpoint**: `GET /api/challenge/poll?device_id=<device_id>`
- **Response Body (200 OK - Pending)**:
```json
{
"status": "pending"
}
```
- **Response Body (200 OK - Approved & Encrypted)**:
```json
{
"status": "approved",
"payload": "-----BEGIN AGE ENCRYPTED FILE-----\n..."
}
```
---
## Bash Client Commands and Usage
### Global Dependencies
- `curl` or `wget` for making HTTP requests.
- `jq` for parsing JSON payloads.
- `ssh-keygen` for Ed25519 key generation and signing.
- `age` and `age-keygen` for decrypting the final payload.
### Command Structure
#### 1. Device Registration Request
Generates local keys, registers with the server, and polls for the encrypted secret payload.
```bash
b me \
--server <server_url> \
[--key-dir <directory_to_save_keys>] \
[--poll-interval <seconds>]
```
- `--server`: Base URL of the bootstrap authentication server.
- `--key-dir`: Directory where the new Ed25519 and age keys will be saved (defaults to `~/.config/bootstrap-client/`).
- `--poll-interval`: Frequency of polling in seconds (defaults to `5`).
#### 2. Request Approval
Retrieves a pending request by its user code, requests user confirmation, signs the approval payload, and sends it to the server.
```bash
b trust <user_code> \
--server <server_url> \
--admin-key <path_to_admin_private_key>
```
- `--code`: The short human readable code displayed on the requesting device.
- `--server`: Base URL of the bootstrap authentication server.
- `--admin-key`: Path to the administrator's private key used to sign the approval.

View File

@@ -0,0 +1,69 @@
# Plugin Development Guide
Plugins are first-party or third-party applications written to work directly with `bootstrap`. Unlike installers (or packages) which modify your system by compiling code, downloading binaries, and altering shell configuration files, **plugins are lazy-loaded scripts that execute within a sandboxed subshell**.
This means downloading and invoking a plugin makes no system modifications other than caching the `.sh` file itself. They are fetched only the very first time you invoke them.
## 1. Writing a Plugin Script
A plugin is fundamentally a single Bash script. When executed by a user via `b <plugin_name>`, `bootstrap` runs the script in a subshell. This guarantees that any variables or state changes your plugin makes to the shell environment will not leak into the parent shell, preserving the integrity of the user's terminal.
Because plugins execute within the `bootstrap` context, you automatically have access to all internal library functions (e.g., `lib/common.sh`, `lib/platform.sh`). For example, you can safely use logging functions like `log_info`, `log_success`, and `log_error`.
Example `my_plugin.sh`:
```bash
#!/usr/bin/env bash
# My Awesome Plugin
# You can use bootstrap's built-in functions natively:
log_info "Initializing awesome plugin..."
if [ "${1:-}" == "--help" ]; then
echo "Usage: b my_plugin [args]"
exit 0
fi
log_success "Task completed successfully!"
```
## 2. Creating a Manifest
For your plugin to be discoverable and dynamically updatable by `bootstrap`, you must provide a JSON manifest. `bootstrap` uses a robust, native Bash-based JSON parser to read this manifest.
Create a JSON file (e.g., `plugins.json`) and host it publicly (e.g., as a GitHub raw URL).
Example `plugins.json`:
```json
{
"plugins": {
"my_plugin": {
"version": "1.0.0",
"url": "https://raw.githubusercontent.com/yourusername/repo/main/my_plugin.sh",
"bootstrap": "2.1.0",
"description": "An awesome plugin that prints logs"
}
}
}
```
* **`version`**: The current semantic version of your plugin. When `bootstrap` detects a version change during `b up`, it automatically clears the cached `.sh` file, forcing a lazy re-download on the next invocation.
* **`url`**: The raw, direct URL to your `.sh` plugin script.
* **`bootstrap`**: The latest version of `bootstrap` that this plugin has been tested against and is compatible with. If the user's `bootstrap` version is newer than this value, a warning is displayed notifying them of potential incompatibility.
## 3. Distribution
To let users install your plugin, simply provide them the raw URL to your JSON manifest.
Users will add it by running:
```bash
b plugin sources
```
They simply append your URL as a new line in the sources file. Once saved, `bootstrap` will automatically fetch your manifest and build a fast-lookup cache. The user can then immediately invoke your plugin:
```bash
b my_plugin
```

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

@@ -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."
@@ -96,7 +84,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 +118,15 @@ 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."
register_tool "agy" "binary" "" "github:sortedcord/agy"
}
configure_shell() {
# Ensure $TARGET_DIR is in PATH for shell configurations if not present
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
done
}
run_handoff() {

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Tool: asciicinema
# DisplayName: asciicinema
# Description: Install asciinema terminal recorder
# Strategy: binary
#
# asciinema Installer Script
#
set -euo pipefail
TMP_DIR="$(make_temp_dir)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
install_asciicinema() {
if has_command curl; then
log_info "Fetching latest asciinema version from GitHub..."
latest_tag=$(github_get_latest_release "asciinema/asciinema")
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
log_info "Downloading asciinema ${latest_tag} for ${arch}..."
github_download_asset "asciinema/asciinema" "$latest_tag" "asciinema-${asciinema_arch}" "$TMP_DIR/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..."
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() {
install_asciicinema
echo
log_success "asciinema installation complete."
}
main "$@"

View File

@@ -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,85 +16,56 @@ 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}..."
curl -fsSL "$deb_url" -o "$TMP_DIR/bat.deb"
log_info "Installing Bat package..."
sudo apt install -y "$TMP_DIR/bat.deb"
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() {
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
done
write_alias_snippet "bat" "alias cat='bat --paging=never -p'"
}
main() {
@@ -108,7 +74,6 @@ main() {
echo
log_success "Bat installation and configuration complete."
log_info "Please close and reopen your terminal or run: source ~/.bashrc to apply changes."
}
main "$@"

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Tool: docker
# DisplayName: Docker
# Description: Container runtime and orchestration platform
# Strategy: system
#
# Docker Installer Script
#
set -euo pipefail
# ─── Installation Logic ──────────────────────────────────────────────
install_docker() {
if has_command docker; then
if ! confirm "Docker is already installed. Reinstall/Upgrade?"; then
log_info "Skipping Docker installation."
return
fi
else
if ! confirm "Install Docker?"; then
log_info "Skipping Docker installation."
return
fi
fi
# Use pkg_install for distro packages (it automatically handles rollback hooks for the packages!)
pkg_install "arch:docker|debian:docker.io|fedora:docker"
# Ensure docker group exists (some distros might not create it immediately)
if ! getent group docker >/dev/null 2>&1; then
log_info "Creating docker group..."
add_rollback_cmd "sudo groupdel docker || true"
sudo groupadd docker
fi
# Configure user group
if ! groups "$USER" | grep -q "\bdocker\b"; then
log_info "Adding $USER to the docker group..."
add_rollback_cmd "sudo gpasswd -d $USER docker || true"
sudo usermod -aG docker "$USER"
log_warn "You will need to log out and log back in, or run 'newgrp docker' for the group changes to take effect."
fi
# Enable and start systemd services
if has_command systemctl; then
log_info "Enabling and starting Docker services..."
# Add rollback cmds for systemd
add_rollback_cmd "sudo systemctl disable --now docker.service || true"
add_rollback_cmd "sudo systemctl disable --now containerd.service || true"
sudo systemctl enable --now docker.service || true
sudo systemctl enable --now containerd.service || true
fi
register_tool "docker" "system" "" "os-package-manager"
}
# ─── Main ─────────────────────────────────────────────────────────────
main() {
install_docker
echo
log_success "Docker installation and configuration complete."
}
main "$@"

71
installers/install_lazygit.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Tool: lazygit
# DisplayName: lazygit
# Description: Simple terminal UI for git commands
# Strategy: binary
#
# lazygit Installer Script
#
set -euo pipefail
# ─── Installation Logic ──────────────────────────────────────────────
install_lazygit() {
if has_command lazygit; then
if ! confirm "lazygit is already installed. Reinstall/Upgrade?"; then
log_info "Skipping lazygit installation."
return
fi
else
if ! confirm "Install lazygit?"; then
log_info "Skipping lazygit installation."
return
fi
fi
local latest_tag=""
latest_tag=$(github_get_latest_release "jesseduffield/lazygit")
if [ -z "$latest_tag" ]; then
latest_tag="v0.62.2" # fallback
log_warn "Failed to fetch latest version. Falling back to: $latest_tag"
fi
local version="${latest_tag#v}"
local arch=$(detect_arch)
local arch_str="x86_64"
if [ "$arch" = "arm64" ]; then
arch_str="arm64"
fi
TMP_DIR="$(make_temp_dir)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
local dest="$TMP_DIR/lazygit.tar.gz"
log_info "Downloading lazygit ${latest_tag}..."
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 "$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 ─────────────────────────────────────────────────────────────
main() {
install_lazygit
echo
log_success "lazygit installation complete."
}
main "$@"

View File

@@ -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
@@ -45,45 +40,36 @@ 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..."
download_file "$nvm_url" "$TMP_DIR/nvm.tar.gz"
curl -fsSL "$nvm_url" -o "$TMP_DIR/nvm.tar.gz"
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 "$BOOTSTRAP_RUNTIMES/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
log_success "NVM source files successfully extracted to $HOME/.nvm."
log_success "NVM source files successfully extracted to $BOOTSTRAP_RUNTIMES/nvm."
}
configure_shell() {
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
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
)
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() {
# 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
@@ -99,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() {
@@ -110,10 +97,9 @@ 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."
log_info "Please close and reopen your terminal or run: source ~/.bashrc to verify."
fi
}

View File

@@ -1,21 +1,17 @@
#!/usr/bin/env bash
# Tool: nvim
# DisplayName: Neovim
# Description: Install Neovim 0.11.7 and configuration
# 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.11.7"
NVIM_INSTALL_DIR="/opt/nvim"
NVIM_VERSION="0.12.0"
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" \
@@ -44,6 +40,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() {
@@ -72,20 +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}..."
curl -fsSL "$nvim_url" -o "$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 "$NVIM_BIN_DIR/nvim"
log_success "Installed:"
nvim --version | head -n1
register_tool "nvim" "binary" "$NVIM_VERSION" "github:neovim/neovim"
}
install_config() {
@@ -99,28 +102,14 @@ 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() {
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
fi
done
write_alias_snippet "nvim" 'alias vim="nvim"'
write_env_snippet "nvim" 'export EDITOR="nvim"'
}
main() {

View File

@@ -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)"
@@ -34,7 +29,11 @@ trap cleanup EXIT
# ─── Helper Functions ─────────────────────────────────────────────────
download() {
if [ -n "${2:-}" ]; then
download_file "$1" "$2"
else
curl -fsSL "$1"
fi
}
is_glibc_compatible() {
@@ -123,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
@@ -147,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
}
@@ -162,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
}
@@ -173,20 +175,21 @@ install_pnpm() {
}
fi
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() {
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
# 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" ;;
@@ -194,15 +197,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 ─────────────────────────────────────────────────────────────
@@ -217,7 +212,6 @@ main() {
log_info "Installed pnpm version: $(pnpm --version 2>/dev/null || echo 'unknown')"
else
log_success "Installation complete."
log_info "Please close and reopen your terminal or run: source ~/.bashrc to verify."
fi
}

View File

@@ -2,25 +2,18 @@
# 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
# Ensure we have curl
install_downloader() {
if ! has_command curl; then
log_info "curl not found. Installing curl..."
pkg_install curl
fi
TMP_DIR="$(make_temp_dir)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
detect_target_triple() {
local ostype
@@ -55,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)
@@ -67,17 +63,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 +75,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"
register_tool "rust" "managed" "" "rustup"
}
configure_shell() {
# Add ~/.cargo/bin to PATH for the current process
export PATH="$HOME/.cargo/bin:$PATH"
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
done
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() {

View File

@@ -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,55 +34,34 @@ 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"
curl -fsSL "$download_url" -o "$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"
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
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 "starship" 'eval "$(starship init bash)"'
}
main() {

View File

@@ -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,58 +43,36 @@ 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"
curl -fsSL "$download_url" -o "$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"
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"
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"
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
done
write_env_snippet "uv" 'eval "$(uv generate-shell-completion bash)"'
}
main() {

View File

@@ -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 ──────────────────────────────────────────────
@@ -30,10 +25,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,10 +56,12 @@ 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..."
rm -rf "$clone_dir"
register_tool "yay" "system" "" "aur:yay-bin"
}
# ─── Main ─────────────────────────────────────────────────────────────

View File

@@ -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,7 +16,6 @@ cleanup() {
trap cleanup EXIT
add_y_wrapper() {
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
local wrapper_content
wrapper_content=$(cat << 'EOF'
@@ -36,84 +30,68 @@ 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() {
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/')
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"
log_info "Installing Yazi package..."
sudo apt install -y "$TMP_DIR/yazi.deb"
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
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() {

View File

@@ -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,29 +25,18 @@ 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"
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
done
write_env_snippet "zoxide" 'eval "$(zoxide init --cmd cd bash)"'
}
main() {

View File

@@ -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
@@ -84,9 +93,89 @@ version_lt() {
done
return 1
}
# Cached and resumable download helper
download_file() {
local url="$1"
local dest="$2"
local cache_dir="$BOOTSTRAP_CACHE_DIR/downloads"
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"
}
# Helper to download multiple files in parallel using background jobs (batched to 10 at a time)
download_multiple_files_parallel() {
# Usage: download_multiple_files_parallel url1 dest1 [url2 dest2 ...]
local urls=()
local dests=()
local exit_code=0
while [ $# -ge 2 ]; do
urls+=("$1")
dests+=("$2")
shift 2
done
local total=${#urls[@]}
local batch_size=10
for ((i=0; i<total; i+=batch_size)); do
local pids=()
local batch_urls=()
# Start up to batch_size background jobs
for ((j=i; j<i+batch_size && j<total; j++)); do
local url="${urls[$j]}"
local dest="${dests[$j]}"
mkdir -p "$(dirname "$dest")" 2>/dev/null || true
curl -fsSL "$url" -o "$dest" &
pids+=($!)
batch_urls+=("$url")
done
# Wait for all background jobs in the current batch to finish
for ((j=0; j<${#pids[@]}; j++)); do
if ! wait "${pids[$j]}"; then
log_warn "Failed to download from ${batch_urls[$j]}"
exit_code=1
fi
done
done
return $exit_code
}
# 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 download_multiple_files_parallel

58
lib/github.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# GitHub API helper functions for Bootstrap installers
# Usage: github_get_latest_release <owner/repo>
# 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 <owner/repo> <tag> <regex_pattern>
# 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 <owner/repo> <tag> <regex_pattern> <dest_file>
# 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

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,142 @@ 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
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
local is_installed=0
if pkg_check "$pkg"; then
is_installed=1
fi
if [ "$is_installed" -eq 1 ]; then
to_remove+=("$pkg")
fi
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

180
lib/plugins.sh Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Parses a plugin manifest using jq and outputs bash array assignments
parse_plugin_manifest() {
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
ensure_sources_file() {
local sources_file="$BOOTSTRAP_DIR/plugin_sources.txt"
if [ ! -f "$sources_file" ]; then
mkdir -p "$BOOTSTRAP_DIR"
echo "# Add raw URLs to JSON plugin manifests here, one per line." > "$sources_file"
echo "# Official Bootstrap plugin repository" >> "$sources_file"
echo "https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/plugins.json" >> "$sources_file"
fi
}
# Fetches manifests from sources and generates the cache
update_plugin_cache() {
ensure_sources_file
local cache_file="$BOOTSTRAP_DIR/lib/plugin_cache.sh"
local sources_file="$BOOTSTRAP_DIR/plugin_sources.txt"
mkdir -p "$BOOTSTRAP_DIR/lib"
# Initialize cache file
cat << 'EOF' > "$cache_file"
# Auto-generated plugin cache. Do not edit manually.
declare -g -A PLUGIN_URLS
declare -g -A PLUGIN_VERSIONS
declare -g -A PLUGIN_BOOTSTRAP_VERSIONS
EOF
if [ -f "$sources_file" ]; then
local dl_args=()
local temp_manifests=()
while IFS= read -r url || [ -n "$url" ]; do
# Skip empty lines and comments
[[ -z "$url" || "$url" == \#* ]] && continue
local temp_file
temp_file=$(mktemp --suffix=".json" 2>/dev/null || mktemp)
dl_args+=("$url" "$temp_file")
temp_manifests+=("$temp_file")
done < "$sources_file"
if [ ${#dl_args[@]} -gt 0 ]; then
log_info "Fetching ${#temp_manifests[@]} plugin manifests in parallel..."
download_multiple_files_parallel "${dl_args[@]}"
for temp_file in "${temp_manifests[@]}"; do
if [ -s "$temp_file" ]; then
cat "$temp_file" | parse_plugin_manifest >> "$cache_file"
fi
rm -f "$temp_file"
done
fi
fi
# Clear downloaded scripts to force lazy re-download of the updated versions
rm -rf "$BOOTSTRAP_DIR/plugins" 2>/dev/null || true
log_success "Plugin cache updated successfully."
}
manage_plugin_sources() {
ensure_sources_file
local sources_file="$BOOTSTRAP_DIR/plugin_sources.txt"
local editor="${EDITOR:-}"
if [ -z "$editor" ]; then
if has_command nvim; then editor="nvim"
elif has_command vim; then editor="vim"
elif has_command nano; then editor="nano"
else editor="vi"
fi
fi
$editor "$sources_file"
# Update cache after editing
update_plugin_cache
}
handle_plugin() {
local subcmd="${1:-}"
case "$subcmd" in
sources)
manage_plugin_sources
;;
update)
update_plugin_cache
;;
*)
log_error "Unknown plugin command: $subcmd"
log_info "Available commands: b plugin sources, b plugin update"
exit 1
;;
esac
}
run_plugin() {
local plugin_name="$1"
shift
local is_ephemeral=false
local cmd_args=()
for arg in "$@"; do
if [ "$arg" = "-e" ] || [ "$arg" = "--ephemeral" ]; then
is_ephemeral=true
else
cmd_args+=("$arg")
fi
done
local url="${PLUGIN_URLS[$plugin_name]:-}"
if [ -z "$url" ]; then
log_error "Plugin '$plugin_name' not found in cache."
return 1
fi
# Check compatibility version
local compat_ver="${PLUGIN_BOOTSTRAP_VERSIONS[$plugin_name]:-}"
if [ -n "$compat_ver" ]; then
local current_ver="0.0.0"
if [ -f "$BOOTSTRAP_DIR/VERSION" ]; then
current_ver=$(cat "$BOOTSTRAP_DIR/VERSION" | tr -d '[:space:]')
fi
if version_lt "$compat_ver" "$current_ver"; then
log_warn "Plugin '$plugin_name' is only tested up to bootstrap version $compat_ver (current: $current_ver). It may be incompatible."
fi
fi
if [ "$is_ephemeral" = "true" ]; then
log_info "Downloading and running plugin '$plugin_name' (ephemeral)..."
local script_content
if ! script_content=$(curl -fsSL "$url"); then
log_error "Failed to download plugin '$plugin_name' from $url"
return 1
fi
# Execute the plugin directly in memory in a subshell
(
export BOOTSTRAP_DIR
# We use bash -c and pass the script content to keep stdin free for interactive plugins
# The "$0" arg for bash -c is set to the plugin name
bash -c "$script_content" "$plugin_name" "${cmd_args[@]}"
)
return $?
else
local plugin_dir="$BOOTSTRAP_DIR/plugins"
local local_plugin="$plugin_dir/${plugin_name}.sh"
if [ ! -f "$local_plugin" ]; then
log_info "Downloading plugin '$plugin_name'..."
mkdir -p "$plugin_dir"
if ! curl -fsSL "$url" -o "$local_plugin"; then
log_error "Failed to download plugin '$plugin_name' from $url"
rm -f "$local_plugin"
return 1
fi
chmod +x "$local_plugin"
fi
log_info "Running plugin '$plugin_name'..."
# Execute the plugin in a subshell, passing any additional arguments
(
export BOOTSTRAP_DIR
bash "$local_plugin" "${cmd_args[@]}"
)
return $?
fi
}

View File

@@ -2,9 +2,12 @@
declare -A INSTALLERS=(
[agy]="Antigravity CLI"
[asciicinema]="asciinema terminal recorder"
[bat]="Bat (alternative to cat) and configure alias"
[docker]="Container runtime and orchestration platform"
[lazygit]="Simple terminal UI for git commands"
[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,7 +19,10 @@ declare -A INSTALLERS=(
declare -A INSTALLER_DISPLAYS=(
[agy]="Antigravity"
[asciicinema]="asciicinema"
[bat]="Bat"
[docker]="Docker"
[lazygit]="lazygit"
[node]="Node"
[nvim]="Neovim"
[pnpm]="Pnpm"
@@ -28,4 +34,21 @@ declare -A INSTALLER_DISPLAYS=(
[zoxide]="Zoxide"
)
INSTALLER_KEYS=(agy bat node nvim pnpm rust starship uv yay yazi 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)

133
lib/registry_helpers.sh Normal file
View File

@@ -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 <tool_name> <strategy> [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 <tool_name> <dep1> <dep2>...
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 <tool_name>
registry_remove_tool() {
local tool="$1"
registry_set 'del(.tools[$tool])' --arg tool "$tool"
}
# Usage: registry_get_sys_deps <tool_name>
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 <tool_name>
# 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

177
lib/rollback.sh Normal file
View File

@@ -0,0 +1,177 @@
#!/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"
init_rollback_system() {
mkdir -p "$BOOTSTRAP_UNINSTALLERS_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"
# 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."
}
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."
}
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."
return 0
fi
local last_line
last_line=$(tail -n 1 "$BOOTSTRAP_HISTORY_LOG")
if [[ "$last_line" == INSTALL:* ]]; then
local tool="${last_line#INSTALL: }"
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."
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: }"
uninstall_tool "$tool"
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 uninstall_tool 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
@@ -31,6 +35,18 @@ else
INSTALLER_KEYS=()
fi
# Source plugin system
if [ -f "$BOOTSTRAP_DIR/lib/plugins.sh" ]; then
. "$BOOTSTRAP_DIR/lib/plugins.sh"
if [ ! -f "$BOOTSTRAP_DIR/lib/plugin_cache.sh" ]; then
# Silently auto-generate cache if missing so official plugins are ready instantly
update_plugin_cache >/dev/null 2>&1 || true
fi
if [ -f "$BOOTSTRAP_DIR/lib/plugin_cache.sh" ]; then
. "$BOOTSTRAP_DIR/lib/plugin_cache.sh"
fi
fi
# Helper function to run/edit installer scripts
run_ware() {
local tool="$1"
@@ -109,9 +125,58 @@ 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"
source_bashrc
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"
@@ -138,6 +203,11 @@ for script in "${SCRIPTS[@]}"; do
else
# Handle non-installer commands
case "$script" in
plugin)
handle_plugin "$@"
# Once handle_plugin completes, we should exit so it doesn't process more SCRIPTS
exit $?
;;
all)
if [ -f "$BOOTSTRAP_DIR/commands/help.sh" ]; then
. "$BOOTSTRAP_DIR/commands/help.sh"
@@ -191,10 +261,37 @@ 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
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
;;
*)
log_error "Unknown command '$script'."
log_info "Run 'b all' to list all available commands."
exit 1
if [[ -n "${PLUGIN_URLS[$script]:-}" ]]; then
run_plugin "$script" "$@"
else
log_error "Unknown command '$script'."
log_info "Run 'b all' to list all available commands."
exit 1
fi
;;
esac
fi

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
@@ -110,7 +166,18 @@ create_fd_symlink() {
fi
}
# Source the bashrc file to reload configurations
source_bashrc() {
if [ -f "$HOME/.bashrc" ]; then
log_info "Re-sourcing ~/.bashrc..."
. "$HOME/.bashrc"
fi
}
# 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 source_bashrc

22
plugins.json Normal file
View File

@@ -0,0 +1,22 @@
{
"plugins": {
"weather": {
"version": "1.0.0",
"url": "https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/plugins/weather.sh",
"bootstrap": "2.2.0",
"description": "Show weather forecast for your location"
},
"sysinfo": {
"version": "1.0.0",
"url": "https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/plugins/sysinfo.sh",
"bootstrap": "2.2.0",
"description": "Show system information and hardware statistics"
},
"todo": {
"version": "1.0.0",
"url": "https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/plugins/todo.sh",
"bootstrap": "2.2.0",
"description": "A simple command-line todo list manager"
}
}
}

74
plugins/sysinfo.sh Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# System Information Dashboard Plugin for bootstrap CLI
main() {
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
echo "Usage: b sysinfo"
echo ""
echo "Displays a beautiful system resource and hardware information dashboard."
return 0
fi
echo -e "${BLUE}==================================================${NC}"
echo -e " ${GREEN}SYSTEM INFORMATION DASHBOARD${NC}"
echo -e "${BLUE}==================================================${NC}"
# OS Info
local os_name="Unknown"
if [ -f /etc/os-release ]; then
os_name=$(grep "^PRETTY_NAME=" /etc/os-release | cut -d= -f2 | tr -d '"')
elif [ "$(uname)" = "Darwin" ]; then
os_name="macOS $(sw_vers -productVersion)"
else
os_name=$(uname -s)
fi
echo -e "${BLUE}OS:${NC} $os_name"
echo -e "${BLUE}Kernel:${NC} $(uname -r)"
echo -e "${BLUE}Uptime:${NC} $(uptime | sed 's/^ *//')"
# CPU Info
local cpu_info="Unknown"
if [ -f /proc/cpuinfo ]; then
cpu_info=$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2 | sed 's/^ *//')
elif [ "$(uname)" = "Darwin" ]; then
cpu_info=$(sysctl -n machdep.cpu.brand_string)
fi
echo -e "${BLUE}CPU:${NC} $cpu_info"
# Load Average
local load_avg
load_avg=$(uptime | awk -F'load average:' '{ print $2 }' | sed 's/^ *//')
echo -e "${BLUE}Load Avg:${NC} $load_avg"
# Memory Usage
echo -e "${BLUE}Memory:${NC}"
if has_command free; then
free -h | awk 'NR==2{printf " Used: %s / Total: %s (%.2f%%)\n", $3, $2, $3/$2*100}'
elif [ -f /proc/meminfo ]; then
local mem_total
mem_total=$(grep "MemTotal" /proc/meminfo | awk '{print $2}')
local mem_free
mem_free=$(grep "MemFree" /proc/meminfo | awk '{print $2}')
local mem_used=$((mem_total - mem_free))
# Convert to MB
local total_mb=$((mem_total / 1024))
local used_mb=$((mem_used / 1024))
local pct=$((used_mb * 100 / total_mb))
echo " Used: ${used_mb}MB / Total: ${total_mb}MB (${pct}%)"
elif [ "$(uname)" = "Darwin" ]; then
local total_mem
total_mem=$(sysctl -n hw.memsize)
local total_gb=$((total_mem / 1024 / 1024 / 1024))
echo " Total: ${total_gb}GB"
else
echo " Unavailable"
fi
# Disk Usage
echo -e "${BLUE}Disk Space (Root):${NC}"
df -h / | awk 'NR==2{printf " Used: %s / Total: %s (%s)\n", $3, $2, $5}'
echo -e "${BLUE}==================================================${NC}"
}
main "$@"

123
plugins/todo.sh Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Todo List Plugin for bootstrap CLI
TODO_FILE="$HOME/.local/share/bootstrap/todo.txt"
main() {
mkdir -p "$(dirname "$TODO_FILE")"
[ ! -f "$TODO_FILE" ] && touch "$TODO_FILE"
local action="${1:-list}"
case "$action" in
add)
shift
if [ -z "$*" ]; then
log_error "Please specify a task to add."
echo "Usage: b todo add <task description>"
return 1
fi
echo "[ ] $*" >> "$TODO_FILE"
log_success "Added task: $*"
;;
list)
if [ ! -s "$TODO_FILE" ]; then
log_info "Your todo list is empty. Add a task with: b todo add <task>"
return 0
fi
echo -e "${BLUE}--- YOUR TODO LIST ---${NC}"
local line_num=1
while IFS= read -r line || [ -n "$line" ]; do
# Highlight completed tasks
if [[ "$line" == "[\x]"* || "$line" == "[x]"* ]]; then
echo -e " ${line_num}. ${GREEN}${line}${NC}"
else
echo -e " ${line_num}. ${line}"
fi
line_num=$((line_num + 1))
done < "$TODO_FILE"
;;
done)
shift
local task_num="${1:-}"
if [[ ! "$task_num" =~ ^[0-9]+$ ]]; then
log_error "Please specify a valid task number."
echo "Usage: b todo done <number>"
return 1
fi
local total_tasks
total_tasks=$(wc -l < "$TODO_FILE")
if [ "$task_num" -lt 1 ] || [ "$task_num" -gt "$total_tasks" ]; then
log_error "Task number out of range (1-$total_tasks)."
return 1
fi
# Update the task at line task_num to be marked [x]
local temp_file
temp_file=$(mktemp)
local line_num=1
while IFS= read -r line || [ -n "$line" ]; do
if [ "$line_num" -eq "$task_num" ]; then
# Replace [ ] with [x]
echo "${line/\[ \]/\[x\]}" >> "$temp_file"
else
echo "$line" >> "$temp_file"
fi
line_num=$((line_num + 1))
done < "$TODO_FILE"
mv "$temp_file" "$TODO_FILE"
log_success "Marked task #$task_num as completed."
;;
rm|remove)
shift
local task_num="${1:-}"
if [[ ! "$task_num" =~ ^[0-9]+$ ]]; then
log_error "Please specify a valid task number to remove."
echo "Usage: b todo rm <number>"
return 1
fi
local total_tasks
total_tasks=$(wc -l < "$TODO_FILE")
if [ "$task_num" -lt 1 ] || [ "$task_num" -gt "$total_tasks" ]; then
log_error "Task number out of range (1-$total_tasks)."
return 1
fi
# Remove the line at task_num
local temp_file
temp_file=$(mktemp)
local line_num=1
while IFS= read -r line || [ -n "$line" ]; do
if [ "$line_num" -ne "$task_num" ]; then
echo "$line" >> "$temp_file"
fi
line_num=$((line_num + 1))
done < "$TODO_FILE"
mv "$temp_file" "$TODO_FILE"
log_success "Removed task #$task_num."
;;
clear)
> "$TODO_FILE"
log_success "Cleared all tasks from your todo list."
;;
--help|-h)
echo "Usage: b todo [action] [args]"
echo ""
echo "Actions:"
echo " list Show all tasks (default)"
echo " add <task> Add a new task"
echo " done <number> Mark a task as completed"
echo " rm <number> Remove a task"
echo " clear Delete all tasks"
return 0
;;
*)
log_error "Unknown action: $action"
echo "Run 'b todo --help' for usage instructions."
return 1
;;
esac
}
main "$@"

32
plugins/weather.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Weather Plugin for bootstrap CLI
main() {
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
echo "Usage: b weather [location]"
echo ""
echo "Fetches and displays a neat weather forecast."
echo "If no location is specified, it auto-detects based on your IP."
return 0
fi
local location="$*"
log_info "Fetching weather forecast..."
if [ -n "$location" ]; then
# URL encode the location (replace spaces with +)
local encoded_location
encoded_location=$(echo "$location" | tr ' ' '+')
if ! curl -sS "wttr.in/${encoded_location}?0&m"; then
log_error "Failed to fetch weather for '$location'."
return 1
fi
else
if ! curl -sS "wttr.in/?0&m"; then
log_error "Failed to fetch weather."
return 1
fi
fi
}
main "$@"

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:
@@ -86,6 +109,58 @@ b up
b up --force
```
## Plugins (`b <plugin_name>`)
Plugins are first-party or third-party applications written to work directly with `bootstrap`. Unlike installers (or packages) which modify your system by compiling code, downloading binaries, and altering shell configuration files, **plugins are lazy-loaded scripts that execute within a subshell**.
Downloading and invoking a plugin makes no system modifications other than caching the `.sh` file itself. They are fetched only the very first time you invoke them.
### Official Plugins
Bootstrap comes pre-configured with a set of official plugins ready to use out-of-the-box:
* **`weather`**: Fetches and displays a neat weather forecast using `wttr.in`.
```bash
b weather
b weather New York
```
* **`sysinfo`**: Displays a system resource dashboard showing CPU, memory, disk usage, and OS/kernel details.
```bash
b sysinfo
```
* **`todo`**: A command-line todo list manager. Tasks are persisted to a lightweight text file in your user directory.
```bash
b todo add "Write a new plugin"
b todo list
b todo done 1
```
### Adding Third-Party Plugins
To manage plugin repositories, run:
```bash
b plugin sources
```
This opens a configuration file in your `$EDITOR`. You can add raw URLs pointing to JSON plugin manifests from any repository. Once you close the editor, `bootstrap` automatically parses those manifests using its native JSON parser and generates a fast, zero-latency lookup cache.
You can then execute any plugin simply by calling its name:
```bash
b my_plugin
```
Plugins are automatically checked for updates and lazily re-downloaded whenever you run `b up`.
If you prefer to run a plugin strictly in **ephemeral mode** (meaning it will bypass the cache and execute directly in memory to guarantee the absolute latest version without leaving any footprint), simply pass the `-e` or `--ephemeral` flag:
```bash
b my_plugin -e
```
For documentation on how to develop and publish your own plugins, please see the [Plugin Development Guide](docs/plugin_development.md).
## Uninstallation
To uninstall the bootstrap helper tool but leave a lightweight `b back` function to easily reinstall it later:

View File

@@ -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"

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"

View File

@@ -10,7 +10,7 @@ IFS='.' read -r cur_major cur_minor cur_patch <<< "${current#v}"
bump=""
auto_confirm=false
message=""
# Parse flags for non-interactive (agent) usage
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -18,6 +18,7 @@ while [[ $# -gt 0 ]]; do
--minor) bump="minor"; shift ;;
--major) bump="major"; shift ;;
-y|--yes) auto_confirm=true; shift ;;
-m|--message) message="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
@@ -58,7 +59,7 @@ if [[ "$confirm" =~ ^[Yy]$ ]]; then
echo "${new_ver#v}" > VERSION
git add VERSION
git commit -m "release: $new_ver"
git tag -a "$new_ver" -m "Release $new_ver"
git tag -a "$new_ver" -m "${message:-Release $new_ver}"
echo "Tagged $new_ver. Push with: git push origin master $new_ver"
else
echo "Aborted."