59 Commits

Author SHA1 Message Date
5ba08f3d20 Merge branch 'master' of git.adityagupta.dev:sortedcord/bootstrap 2026-06-28 09:05:00 +05:30
eefd792b6c docs: Update readme for authentication plugin 2026-06-28 09:04:48 +05:30
4a03e42b9d docs: Update documentation to incorporate for Installation Strategies Redesign 2026-06-28 09:02:18 +05:30
f6df53ae31 Merge pull request 'feat: Add Client Authentication and Provisioning Plugin' (#22) from feat/auth-plugin into master
Reviewed-on: #22
2026-06-28 08:53:40 +05:30
7d4e23e309 Merge branch 'fix/reference-counting' 2026-06-28 08:53:02 +05:30
190f337f12 merge 2026-06-28 08:51:25 +05:30
6e0f951623 merge: Master into fix/reference-counting
Update hyperfine installer to follow the new installer strategy redesign
Update registry
2026-06-28 08:45:30 +05:30
f9a25ff37e refactor(Installers): Update all installers to use library helpers and Update add_installer skill 2026-06-28 08:17:30 +05:30
03e9c20e54 refactor: Linting fixes 2026-06-28 07:53:41 +05:30
2ddd28d4d4 feat: Added Multi-tool uninstallation via comma-separated arguments
- If multiple uninstalled tools share a system dependency, the
dependency is only uninstalled once the last tool using it is removed.
2026-06-28 07:47:14 +05:30
db6ec1c1c8 refactor(plugin:auth): Removed dependency loop in favour of pkg_install 2026-06-27 09:16:11 +05:30
ed56ef95a9 feat: Implemented client spec for bootstrap-auth-server with b me and b trust 2026-06-27 09:04:10 +05:30
671cf7f818 feat(Installer): Add installer for hyperfine 2026-06-27 08:17:31 +05:30
f5227569b1 feat(skill): Update add_installer skill 2026-06-27 08:09:46 +05:30
15d3a1a59d feat: Support for drop-in completions
`shell_config.sh` has support for tool completions using the
`write_completion_snippet` and `write_alias_snippet`
2026-06-27 07:58:10 +05:30
83c524441c fix #19: Prevent Checkpoint from having same bug as 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
- 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 2026-06-26 21:49:24 +05:30
29de051b7d refactor: Remove standalone exec prevention code blocks from installers 2026-06-26 21:40:48 +05:30
a4e5bc1175 refactor: Remove legacy backwards compat code
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
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 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 2026-06-26 18:36:39 +05:30
7f3ff45f05 refactor: Use jq instead of custom posix complient json.sh
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 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
49 changed files with 3250 additions and 618 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, write_completion_snippet
│ ├── registry.sh # Dynamically generated installer registry
│ └── routes.sh # Central router script
├── commands/ # Non-installer commands (help, con, uninstall)
@@ -34,28 +35,48 @@ bootstrap/
When adding a new installer named `<name>`:
### Step 1: Create the installer script
### Step 1: Analyze user request & gather details
Create `installers/install_<name>.sh` using the template below.
When the user asks you to create an installer, they often provide either an official `curl` install script or a link to a `.tar.gz` release.
You MUST do the following before writing the script:
If the user provides an official install or curl script in the prompt:
- Read and analyze the script.
- Remove redundant parts like macOS and Windows compatibility.
- Strip unnecessary shell boilerplate, self-update logic, and other bloat.
- Implement only the essential Linux installation logic inside the `install_<name>` function.
If the user provides an official install or curl script in the prompt, or a link to one:
- Execute the `curl` command (or use `read_url_content` or `read_browser_page`) to fetch the script and analyze what it actually does under the hood.
- Do NOT simply execute the official script blindly in your installer.
- Re-write its functionality according to the conventions of the bootstrap installer.
- Strip away redundant code, OS checks for macOS/Windows (we only target Linux), unnecessary shell configuration logic, and self-update logic.
- Implement only the core, essential Linux installation logic inside the `install_<name>` function.
- Do NOT add checks for `curl` or attempt to install it. Assume `curl` is installed and available.
### Step 2: Add metadata comments to the top of your installer script
**If the user provides a link to a `.tar.gz` (or `.zip`):**
- First, download the archive to a temporary directory and extract it to inspect its contents.
- Analyze the extracted folder structure to decide what needs to be installed (e.g., binaries, man pages, completions) and what should be ignored/deleted.
- Write the `install_<name>` function to download, extract, and copy only those essential files. (Use `download_file` and temporary directories, see "Resumable Download and Extraction" below).
At the top of your new installer script, right below `#!/usr/bin/env bash`, add the following three metadata headers:
### Step 2: Create the installer script
### Step 3: Add metadata comments to the top of your installer script
At the top of your new installer script, right below `#!/usr/bin/env bash`, add the following metadata headers:
```bash
# Tool: <name>
# DisplayName: <displayName>
# Description: <description>
# Strategy: <strategy> (binary | managed | system)
```
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 4: Implement Rollback Tracking (Crucial)
To ensure the user can seamlessly use `b rb <name>` to uninstall or rollback a tool, all manual modifications must be tracked:
- When extracting binaries, copy them to `$BOOTSTRAP_BIN` and use `track_file "$BOOTSTRAP_BIN/binary"`. Never use `sudo` or write to system folders like `/usr/local/bin`.
- When creating directories (e.g., in `$BOOTSTRAP_OPT` or `$BOOTSTRAP_RUNTIMES`), use `track_dir` to register them.
- When running manual commands (like compiling or registry changes), log their inverses: `add_rollback_cmd "rm -rf ..."` or equivalent cleanup.
- When installing packages via `pkg_install`, always register them with `registry_add_sys_deps` for reference-counted rollback tracking.
- Note: `write_env_snippet`, `write_alias_snippet`, and `write_completion_snippet` will automatically track themselves.
### Step 5: Verify (optional)
Verify that the installer works and appears in the help output:
- Run `b all` to confirm it appears in the help list.
@@ -66,22 +87,15 @@ Verify that the installer works and appears in the help output:
## Installer Script Template
Every installer follows this exact boilerplate structure. Copy this and fill in the tool-specific logic:
Every installer follows this exact structure. Copy this and fill in the tool-specific logic (notice there are no standalone execution guards or curl checks):
```bash
#!/usr/bin/env bash
# Tool: <name>
# DisplayName: <ToolName>
# Description: Short description of what it installs
# Strategy: <strategy> (binary | managed | system)
#
# <ToolName> 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
@@ -102,34 +116,23 @@ install_<name>() {
# --- 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)
# 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/binary"
# track_file "$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>'"
# write_completion_snippet "<name>" "source <(<command> completion bash)"
:
}
# ─── Main ─────────────────────────────────────────────────────────────
@@ -162,6 +165,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 `$BOOTSTRAP_CACHE_DIR/downloads/`. |
### From `lib/platform.sh`
@@ -169,18 +173,32 @@ 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"`. |
| `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 "rm -rf <path>"`). |
### 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. |
| `write_completion_snippet <name> <content>` | Creates an isolated `completions.d/` bash completion snippet and registers it for rollback. |
### From `lib/github.sh`
| Function | Description |
|---|---|
| `github_get_latest_release <repo>` | Fetches the latest release tag (e.g., `v1.0.0`) from the GitHub API. |
| `github_get_download_url <repo> <tag> <regex>` | Finds an asset matching a regex in the specified release and prints its URL. |
| `github_download_asset <repo> <tag> <regex> <dest>` | Resolves the URL for the matching asset and downloads it directly to `<dest>`. |
---
@@ -194,38 +212,17 @@ 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)
fi
latest_tag=$(github_get_latest_release "owner/repo")
if [ -z "$latest_tag" ]; then
latest_tag="v1.0.0" # fallback
@@ -233,11 +230,18 @@ if [ -z "$latest_tag" ]; then
fi
```
### Shell block injection (idempotent)
### GitHub Download and Extraction
```bash
# Block name should be unique and descriptive
inject_block "$config_file" "<tool> init" 'eval "$(tool init bash)"'
local archive="$TMP_DIR/archive.tar.gz"
# Download the asset matching a regex pattern for the specific release tag
github_download_asset "owner/repo" "$latest_tag" "app-.*-linux-x86_64\.tar\.gz" "$archive"
# Extract and install
tar -xzf "$archive" -C "$TMP_DIR"
cp "$TMP_DIR/binary" "$BOOTSTRAP_BIN/binary"
track_file "$BOOTSTRAP_BIN/binary"
```
---
@@ -245,11 +249,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.
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.
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 `$BOOTSTRAP_BIN`, you MUST call `track_file`. If you build/install manually (e.g. via `makepkg -si`), you MUST call `add_rollback_cmd "sudo pacman -R --noconfirm <pkg>"` or equivalent.
4. **Shell Drop-ins**: Always use `write_env_snippet`, `write_alias_snippet`, or `write_completion_snippet` instead of manually injecting code directly into `~/.bashrc`.
5. **No hardcoded paths**: Use `$HOME`, `$BOOTSTRAP_BIN`, `$BOOTSTRAP_OPT`, `$BOOTSTRAP_RUNTIMES`, library functions, and `detect_*` helpers.
6. **Error handling**: Use `set -euo pipefail`.
7. **No Standalone Execution Guards**: Do NOT add guards to prevent running the installer directly; simplify code paths to focus on clean runs.
8. **Clean Official Scripts**: When implementing official curl/install scripts, strip them of bloat, macOS/Windows support, and redundant shell setups. Do not check or install `curl`.
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

19
b.sh
View File

@@ -1,3 +1,4 @@
# shellcheck shell=bash
# >>> bootstrap-cli b function >>>
# Shortcut for downloading and running bootstrap/install scripts
b() {
@@ -19,6 +20,7 @@ b() {
_version_lt() {
[ "$1" = "$2" ] && return 1
local IFS=.
# shellcheck disable=SC2206
local i ver1=($1) ver2=($2)
for ((i=${#ver1[@]}; i<3; i++)); do ver1[i]=0; done
for ((i=${#ver2[@]}; i<3; i++)); do ver2[i]=0; done
@@ -41,7 +43,7 @@ b() {
local base_url="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master"
local local_ver="0.0.0"
[ -f "$routes_dir/VERSION" ] && local_ver=$(cat "$routes_dir/VERSION" 2>/dev/null | tr -d '[:space:]')
[ -f "$routes_dir/VERSION" ] && local_ver=$(tr -d '[:space:]' < "$routes_dir/VERSION" 2>/dev/null)
local remote_ver
if remote_ver=$(curl -fsSL "$base_url/VERSION" 2>/dev/null); then
@@ -66,6 +68,16 @@ 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
# shellcheck source=/dev/null
. "$HOME/.bashrc"
fi
fi
return $ret
}
# Autocompletion for the b command in Bash
@@ -77,7 +89,7 @@ _b_completion() {
# If completing the first argument after 'b'
if [ "$COMP_CWORD" -eq 1 ]; then
opts="all con gone up ware bware"
opts="all con gone up ware bware me trust"
local routes_dir="$HOME/.config/bootstrap"
local installer_keys=""
@@ -114,6 +126,7 @@ _b_completion() {
return 0
fi
# shellcheck disable=SC2207
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
return 0
fi
@@ -152,6 +165,7 @@ _b_completion() {
return 0
fi
# shellcheck disable=SC2207
COMPREPLY=( $(compgen -W "$installer_keys" -- "$cur") )
return 0
fi
@@ -161,6 +175,7 @@ _b_completion() {
# List of directories in ~/.config/ to choose from
local config_dirs
config_dirs=$(find "$HOME/.config" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null)
# shellcheck disable=SC2207
COMPREPLY=( $(compgen -W "$config_dirs" -- "$cur") )
return 0
fi

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
@@ -25,7 +20,7 @@ fi
# Locate or download libraries so that sourced installers can use them
BOOTSTRAP_DIR="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}"
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null || pwd)"
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _SCRIPT_DIR="$(pwd)"
if [ -f "$_SCRIPT_DIR/lib/common.sh" ]; then
# Dev/local mode: source directly from repo
@@ -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,27 @@ install_bootstrap() {
[ -f "$HOME/.bashrc" ] && target_files+=("$HOME/.bashrc")
local routes_dir="$HOME/.config/bootstrap"
mkdir -p "$routes_dir"
mkdir -p "$routes_dir/env.d"
mkdir -p "$routes_dir/aliases.d"
mkdir -p "$routes_dir/completions.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 +94,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
@@ -97,6 +125,12 @@ install_bootstrap() {
mkdir -p "$routes_dir/installers"
cp -r "$_SCRIPT_DIR/installers/"* "$routes_dir/installers/"
fi
# Also copy plugins if they exist locally
if [ -d "$_SCRIPT_DIR/plugins" ]; then
mkdir -p "$routes_dir/plugins"
cp -r "$_SCRIPT_DIR/plugins/"* "$routes_dir/plugins/"
fi
else
log_info "Downloading bootstrap scripts..."
local base_url="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master"
@@ -126,13 +160,23 @@ 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
for f in "$BOOTSTRAP_DIR/completions.d/"*.sh; do [ -r "$f" ] && . "$f"; done
# <<< bootstrap-cli setup <<<
EOF
@@ -232,7 +276,7 @@ EOF
# Display centered version of bootstrap in bold
_version=""
if [ -f "$_version_file" ]; then
_version=$(cat "$_version_file" | tr -d '\r\n')
_version=$(tr -d '\r\n' < "$_version_file")
fi
if [ -z "$_version" ]; then
_version="0.0.0" # Fallback if VERSION missing
@@ -299,6 +343,7 @@ EOF
# Load the b function immediately in the current subshell
if [ -f "$HOME/.config/bootstrap/b.sh" ]; then
# shellcheck source=/dev/null
. "$HOME/.config/bootstrap/b.sh"
fi
@@ -311,6 +356,7 @@ else
# Sourced mode (e.g., when sourced by installers or manually by user)
# Load the b function in the current shell context
if [ -f "$HOME/.config/bootstrap/b.sh" ]; then
# shellcheck source=/dev/null
. "$HOME/.config/bootstrap/b.sh"
fi
fi

View File

@@ -1,3 +1,4 @@
# shellcheck shell=bash
# Command: con
# Edits configurations in ~/.config/

View File

@@ -1,3 +1,4 @@
# shellcheck shell=bash
# Command: help
# Lists all available bootstrap commands and installers

View File

@@ -1,3 +1,4 @@
# shellcheck shell=bash
# Command: uninstall (gone)
# Removes bootstrap CLI and cleans up shell configuration files
@@ -45,7 +46,6 @@ if [ -f "$HOME/.bash_aliases" ]; then
# 2. Remove specific aliases added by bootstrap (e.g. vim -> nvim)
if grep -q '^alias vim="nvim"$' "$HOME/.bash_aliases" 2>/dev/null; then
local tmp_file
tmp_file=$(mktemp)
sed '/^alias vim="nvim"$/d' "$HOME/.bash_aliases" > "$tmp_file"
cat "$tmp_file" > "$HOME/.bash_aliases"
@@ -61,7 +61,6 @@ fi
# If force is false, leave a lightweight 'b back' shortcut function in shell config files
if [ "$FORCE" = "false" ]; then
local b_back_content
b_back_content=$(cat << 'EOF'
b() {
if [ "${1:-}" = "back" ]; then
@@ -78,6 +77,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

@@ -1,9 +1,11 @@
# shellcheck shell=bash
# Command: up
# Manually checks for updates and runs the updater if a newer version is found.
# Source libraries if needed
if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
_LIB_DIR="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/lib"
# shellcheck source=/dev/null
. "$_LIB_DIR/common.sh"
fi
@@ -14,7 +16,7 @@ log_info "Checking for updates..."
local_ver="0.0.0"
version_file="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/VERSION"
if [ -f "$version_file" ]; then
local_ver=$(cat "$version_file" | tr -d '[:space:]')
local_ver=$(tr -d '[:space:]' < "$version_file")
fi
base_url="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master"
@@ -48,6 +50,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,389 @@
# Installation Strategies & Installer Lifecycle
This document explains how Bootstrap categorizes tool installations, the anatomy of an installer script, the full execution lifecycle from invocation to registry recording, and the helper infrastructure available to installers.
## 1. Installation Strategies
Every installer declares an explicit **strategy** that describes how it provisions software onto the system. The strategy is recorded in two places:
1. **At authoring time** — as a `# Strategy:` metadata comment in the installer script header.
2. **At runtime** — inside `$BOOTSTRAP_STATE_DIR/registry.json` via `register_tool`.
### Strategy Types
| Strategy | Description | Validation (`registry_check`) |
| :--- | :--- | :--- |
| `binary` | Tool is downloaded as a prebuilt binary (typically from a GitHub release) and placed into `$BOOTSTRAP_BIN`. | Confirms the registered binary path exists and is executable. |
| `managed` | Tool is installed through its own dedicated version manager or installer (e.g. NVM for Node.js, `rustup` for Rust). Bootstrap orchestrates the manager but doesn't own the binary directly. | Confirms the tool is available via `command -v`. |
| `system` | Tool is installed entirely through the system package manager (`pacman`, `apt`, `dnf`). | Validates recorded system packages using `pkg_check`, falling back to `command -v` when no dependencies are recorded. |
### Which Strategy to Use
- **Prefer `binary`** when the upstream project provides standalone Linux binaries on GitHub (e.g. `bat`, `lazygit`, `hyperfine`, `yazi`, `nvim`).
- **Use `managed`** when the tool has its own version/runtime manager that must be preserved for updates (e.g. `nvm` for Node.js, `rustup` for Rust, `zoxide` which uses its own install script).
- **Use `system`** when the tool is only available through the distro's package manager and there is no upstream binary distribution (e.g. `docker`, `yay`).
## 2. Installer Script Anatomy
Every installer lives at `installers/install_<tool>.sh` and follows a strict structure.
### 2.1 Metadata Header
The first lines of the file are structured comments parsed by `scripts/generate_registry.sh` to auto-generate `lib/registry.sh`:
```bash
#!/usr/bin/env bash
# Tool: bat
# DisplayName: Bat
# Description: Install Bat (alternative to cat) and configure alias
# Strategy: binary
#
```
| Field | Purpose |
| :--- | :--- |
| `Tool` | The CLI key used to invoke the installer (`b bat`). Must be lowercase, no spaces. |
| `DisplayName` | Human-readable name shown in `b ware` listings and log messages. |
| `Description` | One-line description. The word "Install" at the start is stripped automatically by the registry generator. |
| `Strategy` | One of `binary`, `managed`, or `system`. |
### 2.2 Script Body Structure
```bash
set -euo pipefail
# Temporary directory (auto-cleaned)
TMP_DIR="$(make_temp_dir)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
# Core installation function
install_<tool>() {
# 1. Check if already installed, offer reinstall/upgrade
# 2. Detect architecture, fetch latest version
# 3. Download, extract, install
# 4. Track files for rollback
# 5. Register in the JSON registry
}
# Optional: shell environment configuration
configure_shell() {
# Write env/alias/completion snippets
}
main() {
install_<tool>
configure_shell # if needed
echo
log_success "<Tool> installation and configuration complete."
}
main "$@"
```
### 2.3 Key Conventions
- **No standalone execution guards**: Installers are always executed by the Bootstrap runtime, which sources all libraries before running the script. They must never check for or source libraries themselves.
- **No `curl` availability checks**: The runtime guarantees `curl` is available.
- **No direct `.bashrc` manipulation**: Use `write_env_snippet`, `write_alias_snippet`, and `write_completion_snippet` to write shell configuration fragments into the snippet directories (`env.d/`, `aliases.d/`, `completions.d/`). The loader sources these automatically.
- **Temp directories**: Use `make_temp_dir` (from `lib/common.sh`) for temporary work and clean up via a `trap cleanup EXIT`.
## 3. Available Helper Functions
Installers run inside a prepared environment where all Bootstrap libraries have been sourced. The following helper functions are available:
### 3.1 Common Utilities (`lib/common.sh`)
| Function | Purpose |
| :--- | :--- |
| `log_info`, `log_success`, `log_warn`, `log_error` | Coloured logging to stdout/stderr. |
| `confirm "<prompt>"` | Interactive yes/no prompt. Reads from `/dev/tty` to support piped execution. |
| `has_command <cmd>` | Returns `0` if the command exists on `$PATH`. |
| `make_temp_dir` | Creates a `mktemp -d` temporary directory and prints its path. |
| `download_file <url> <dest>` | Cached, resumable download. Files are first stored in `$BOOTSTRAP_CACHE_DIR/downloads/` and then copied to `<dest>`. |
| `version_lt <v1> <v2>` | Semver comparison. Returns `0` if `v1 < v2`. |
### 3.2 Platform & Package Management (`lib/platform.sh`)
| Function | Purpose |
| :--- | :--- |
| `detect_distro` | Returns `arch`, `debian`, `fedora`, or `unknown`. |
| `detect_arch` | Returns `x86_64` or `arm64`. |
| `pkg_install <spec>...` | Installs system packages. Supports distro-specific mappings: `"arch:docker\|debian:docker.io\|fedora:docker"`. |
| `pkg_check <spec>...` | Returns `0` if all specified packages are installed. |
| `pkg_remove <spec>...` | Removes system packages. |
### 3.3 GitHub Release Helpers (`lib/github.sh`)
| Function | Purpose |
| :--- | :--- |
| `github_get_latest_release <owner/repo>` | Queries the GitHub API and prints the `tag_name` of the latest release. |
| `github_get_download_url <owner/repo> <tag> <regex>` | Finds the asset matching the regex pattern in the given release and prints its download URL. |
| `github_download_asset <owner/repo> <tag> <regex> <dest>` | Resolves and downloads the matching asset to `<dest>`. |
**Why `github_get_latest_release` is separate from `github_download_asset`**: Asset filenames often embed the version string (e.g. `bat-v0.26.1-x86_64-unknown-linux-gnu.tar.gz`). The installer needs the concrete version *before* constructing the asset pattern and also passes it to `register_tool`.
### 3.4 Shell Configuration (`lib/shell_config.sh`)
| Function | Purpose |
| :--- | :--- |
| `write_env_snippet <name> <content>` | Writes content to `$BOOTSTRAP_DIR/env.d/<name>.sh`. Auto-registers a rollback command to delete the snippet. |
| `write_alias_snippet <name> <content>` | Writes content to `$BOOTSTRAP_DIR/aliases.d/<name>.sh`. Auto-registers rollback. |
| `write_completion_snippet <name> <content>` | Writes content to `$BOOTSTRAP_DIR/completions.d/<name>.sh`. Auto-registers rollback. |
### 3.5 Rollback & File Tracking (`lib/rollback.sh`)
| Function | Purpose |
| :--- | :--- |
| `track_file <path>` | Records `sudo rm -f '<path>'` in the uninstaller manifest for the current tool. |
| `track_dir <path>` | Records `sudo rm -rf '<path>'` in the uninstaller manifest. |
| `add_rollback_cmd <cmd>` | Prepends an arbitrary shell command to the manifest (LIFO order). |
### 3.6 Registry Helpers (`lib/registry_helpers.sh`)
| Function | Purpose |
| :--- | :--- |
| `register_tool <tool> <strategy> [version] [source]` | Records the tool in `registry.json` with its strategy, version, source, timestamp, and (for `binary` strategy) its binary path. |
| `registry_add_sys_deps <tool> <dep1> <dep2>...` | Appends system package dependency names to the tool's `system_dependencies` array in the registry (used for reference counting during uninstallation). |
| `registry_remove_tool <tool>` | Deletes the tool's entire entry from `registry.json`. |
| `registry_check <tool>` | Validates that the tool is correctly installed according to its declared strategy. |
## 4. Execution Lifecycle
This is the complete flow from when a user types `b <tool>` to when the installation is recorded.
### 4.1 Invocation
```
User types: b bat
b.sh (shell function)
├── Auto-update check (once per 24h)
bash routes.sh bat
```
`b.sh` is a shell function sourced from `~/.bashrc`. It delegates to `routes.sh` in a subshell.
### 4.2 Library Loading
`routes.sh` sources the core libraries in order:
1. `lib/common.sh` — Logging, `download_file`, `make_temp_dir`, XDG path variables
2. `lib/rollback.sh` — Manifest system, `track_file`, `track_dir`, `init_rollback_system`
3. `lib/platform.sh` — Distro/arch detection, `pkg_install`/`pkg_remove`
4. `lib/shell_config.sh``write_env_snippet`, `write_alias_snippet`, etc.
5. `lib/registry.sh` — Auto-generated lookup tables (`INSTALLERS`, `INSTALLER_DISPLAYS`, `INSTALLER_STRATEGIES`, `INSTALLER_KEYS`)
6. `lib/registry_helpers.sh``register_tool`, `registry_add_sys_deps`, etc.
7. `lib/github.sh` — GitHub release API helpers
8. `lib/plugins.sh` — Plugin system
All functions are exported to subshells so the installer script inherits them.
### 4.3 Routing
`routes.sh` resolves the command. If the argument matches a key in the `INSTALLERS` registry, it is treated as a tool name and routed to `run_ware`:
```
routes.sh receives "bat"
├── Is it a built-in command (help, rb, fall, con, up, gone, etc.)? → No
├── Is it a registered tool key in INSTALLERS? → Yes
run_ware "bat" "-y"
```
### 4.4 Installer Resolution
`run_ware` locates the installer script:
1. **Local check**: Does `$BOOTSTRAP_DIR/installers/install_bat.sh` exist? If yes, use it directly.
2. **Remote download**: If not local, download from the primary Git server (`git.adityagupta.dev`). On failure, fall back to the GitHub mirror.
### 4.5 Pre-Execution Setup
Before running the installer, `run_ware` calls `setup_uninstaller_context "bat"`, which:
- Sets `BOOTSTRAP_CURRENT_TOOL=bat`
- Sets `BOOTSTRAP_UNINSTALLER_CMDS=$BOOTSTRAP_STATE_DIR/uninstallers/bat.cmds`
- If a manifest already exists from a previous interrupted run, it preserves it (resume support).
- Otherwise, creates a fresh empty manifest file.
Signal traps (`INT`, `TERM`) are installed to catch interruptions.
### 4.6 Installer Execution
The installer script runs in a `bash` subshell. During execution:
- **`track_file`/`track_dir`** calls prepend removal commands to `bat.cmds` (LIFO order).
- **`write_env_snippet`/`write_alias_snippet`** automatically record their own rollback commands.
- **`pkg_install`** installs system packages. If the tool uses `system` strategy, the installer calls `registry_add_sys_deps` to register these packages for reference counting.
- **`register_tool "bat" "binary" "$version" "github:sharkdp/bat"`** writes the tool's metadata to `$BOOTSTRAP_STATE_DIR/registry.json` using thread-safe `flock`.
### 4.7 Post-Execution
If the installer exits successfully (`exit 0`), `run_ware`:
1. Calls `mark_install_success "bat"` — appends `INSTALL: bat` to `$BOOTSTRAP_STATE_DIR/history.log`.
2. Re-sources `~/.bashrc` so new env/alias snippets take effect immediately.
If the installer fails or is interrupted, the user is offered a choice:
- **Rollback (`r`)**: Executes the manifest line-by-line to undo all recorded changes.
- **Keep (`k`)**: Preserves partial changes so the user can resume or debug.
## 5. The Registry (`registry.json`)
The JSON registry is the central source of truth for installed tool state. It lives at `$BOOTSTRAP_STATE_DIR/registry.json`.
### 5.1 Schema
```json
{
"tools": {
"bat": {
"strategy": "binary",
"version": "v0.26.1",
"source": "github:sharkdp/bat",
"installed_at": "2025-06-28T03:15:00Z",
"bin": "/home/user/.local/share/bootstrap/bin/bat"
},
"docker": {
"strategy": "system",
"installed_at": "2025-06-28T03:20:00Z",
"source": "os-package-manager",
"system_dependencies": [
"arch:docker|debian:docker.io|fedora:docker"
]
},
"node": {
"strategy": "managed",
"version": "v0.40.5",
"source": "github:nvm-sh/nvm",
"installed_at": "2025-06-28T03:25:00Z"
}
}
}
```
### 5.2 Thread Safety
All mutations go through `registry_set`, which acquires an exclusive file lock (`flock -x`) on `registry.json.lock` before applying a `jq` filter. This prevents race conditions if multiple installers run simultaneously.
## 6. The Auto-Generated Registry (`lib/registry.sh`)
`lib/registry.sh` is a **build-time artifact**, not a runtime state file. It is auto-generated by `scripts/generate_registry.sh`, which parses the metadata headers from every `installers/install_*.sh` file and produces four Bash data structures:
| Variable | Type | Purpose |
| :--- | :--- | :--- |
| `INSTALLERS` | `declare -A` | Maps tool key → description |
| `INSTALLER_DISPLAYS` | `declare -A` | Maps tool key → human-readable display name |
| `INSTALLER_STRATEGIES` | `declare -A` | Maps tool key → strategy (`binary`/`managed`/`system`) |
| `INSTALLER_KEYS` | Array | Sorted list of all tool keys |
These are used by `routes.sh` for command routing, by `b ware` (with no arguments) for listing available tools, and by `b help` for the help output.
**Important**: `lib/registry.sh` must be regenerated whenever an installer is added, removed, or has its metadata header changed. Run:
```bash
./scripts/generate_registry.sh
```
## 7. Uninstallation Flow
### 7.1 Named Uninstall (`b rb <tool>`)
```
b rb bat
uninstall_tool "bat"
├── 1. execute_rollback "bat"
│ → Reads bat.cmds line-by-line (LIFO)
│ → Executes: rm binary, rm env snippet, rm alias snippet, etc.
│ → Deletes bat.cmds
├── 2. Reference-count system dependencies
│ → For each dep in registry_get_sys_deps("bat"):
│ → Count how many OTHER tools also list this dep
│ → If count == 0: pkg_remove the dep
│ → If count > 0: keep it
├── 3. registry_remove_tool "bat"
│ → Deletes .tools.bat from registry.json
└── 4. Remove "INSTALL: bat" from history.log
```
### 7.2 Multi-Tool Uninstall (`b rb bat,yazi,zoxide`)
The argument is split on commas and `uninstall_tool` is called for each tool sequentially.
### 7.3 Bare Rollback (`b rb`)
Reads the last line of `history.log`. If it is `INSTALL: <tool>`, calls `uninstall_tool` for that tool.
### 7.4 Savepoint Rollback (`b rb <savepoint>`)
If the argument doesn't match a registered tool, it is treated as a savepoint name. The system walks `history.log` backwards, uninstalling each tool encountered until it reaches the matching `SAVEPOINT:` entry.
## 8. Strategy-Specific Patterns
### 8.1 Binary Strategy Example (Bat)
```bash
# Detect arch, fetch latest tag from GitHub
latest_tag=$(github_get_latest_release "sharkdp/bat")
# Download the matching release asset
github_download_asset "sharkdp/bat" "$latest_tag" "bat-${latest_tag}-${target}\.tar\.gz" "$archive"
# Extract, copy binary to $BOOTSTRAP_BIN, make executable
cp "$extract_dir/bat" "$BOOTSTRAP_BIN/bat"
chmod +x "$BOOTSTRAP_BIN/bat"
track_file "$BOOTSTRAP_BIN/bat"
# Register
register_tool "bat" "binary" "$latest_tag" "github:sharkdp/bat"
```
### 8.2 Managed Strategy Example (Node.js via NVM)
```bash
# Download and extract NVM into $BOOTSTRAP_RUNTIMES/nvm
download_file "$nvm_url" "$TMP_DIR/nvm.tar.gz"
tar -xzf "$TMP_DIR/nvm.tar.gz" -C "$BOOTSTRAP_RUNTIMES/nvm" --strip-components=1
track_dir "$BOOTSTRAP_RUNTIMES/nvm"
# Write env snippet so NVM loads on shell startup
write_env_snippet "node" "$content"
# Use NVM to install Node LTS
nvm install --lts
# Register (NVM manages the actual node binary)
register_tool "node" "managed" "$latest_tag" "github:nvm-sh/nvm"
```
### 8.3 System Strategy Example (Docker)
```bash
# Install via system package manager
pkg_install "arch:docker|debian:docker.io|fedora:docker"
# Register the dependency for reference counting
registry_add_sys_deps "docker" "arch:docker|debian:docker.io|fedora:docker"
# Additional system configuration (groups, systemd)
sudo usermod -aG docker "$USER"
add_rollback_cmd "sudo gpasswd -d $USER docker || true"
# Register
register_tool "docker" "system" "" "os-package-manager"
```

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

135
docs/rollback_design.md Normal file
View File

@@ -0,0 +1,135 @@
# 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: Procedural Manifests & JSON Registry
The system uses a hybrid approach:
1. **Procedural Command Manifests**: As the installation progresses, a LIFO script manifest (`$BOOTSTRAP_STATE_DIR/uninstallers/<tool>.cmds`) is built by prepending the inverse commands for each helper action (files, directories, env/alias snippets).
2. **Centralized JSON Registry**: Metadata, strategy, and system-level dependencies are tracked in a thread-safe `$BOOTSTRAP_STATE_DIR/registry.json` using `jq`. During uninstallation, this registry is used to reference-count and safely remove shared system dependencies.
## 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 installation:
1. Reads the last line of the history log (e.g., `INSTALL: nvim`).
2. Runs `uninstall_tool nvim` which executes `nvim.cmds` and cleans up registry entries.
### D. Named Rollback (`b rb <tool1>,<tool2>...`)
Users can uninstall specific tools by name (e.g., `b rb nvim` or `b rb nvim,yazi`). The system runs the corresponding uninstaller manifests, reference-counts and removes any orphaned system dependencies, and cleans up the history log and registry.
### E. Savepoint Rollback (`b rb <savepoint>`)
If the argument does not match an installed tool in the registry, it is treated as a savepoint:
1. Parses the history log from bottom to top.
2. For each `INSTALL: <tool>` encountered, it runs `uninstall_tool <tool>`.
3. Stops when it reaches the specified `SAVEPOINT: <name>`.
4. Truncates the history log back to that 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. Helper Operations
Helper functions record their inverse actions directly to the manifest:
- **`write_env_snippet` / `write_alias_snippet` / `write_completion_snippet`:**
```bash
add_rollback_cmd "rm -f \"\$BOOTSTRAP_DIR/env.d/\$snippet_name.sh\""
```
- **`track_file` / `track_dir`**:
```bash
track_file() { add_rollback_cmd "sudo rm -f '$1'"; }
track_dir() { add_rollback_cmd "sudo rm -rf '$1'"; }
```
Note: distro packages are not tracked via `add_rollback_cmd`. Instead, installers declare them as system dependencies (see below).
## 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
System dependencies installed via `pkg_install` must be registered via `registry_add_sys_deps <tool> <dep1> <dep2>...`.
When a tool is uninstalled:
1. The registry is checked to see if any other installed tool still lists those dependencies.
2. If the reference count drops to `0`, the dependency is automatically removed using `pkg_remove`.
3. If other tools still depend on it, it is kept.
## 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,69 @@
#!/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 and explicitly register them for reference-counted rollback
pkg_install "arch:docker|debian:docker.io|fedora:docker"
registry_add_sys_deps "docker" "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 "$@"

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Tool: hyperfine
# DisplayName: Hyperfine
# Description: Command-line benchmarking tool
# Strategy: binary
#
set -euo pipefail
TMP_DIR="$(make_temp_dir)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
install_hyperfine() {
if has_command hyperfine; then
if ! confirm "Hyperfine is already installed. Reinstall/Upgrade?"; then
log_info "Skipping Hyperfine installation."
return
fi
else
if ! confirm "Install Hyperfine?"; then
log_info "Skipping Hyperfine installation."
return
fi
fi
# Detect architecture
local raw_arch
raw_arch=$(detect_arch)
local arch=""
case "$raw_arch" in
x86_64) arch="x86_64" ;;
arm64) arch="aarch64" ;;
*) log_error "Unsupported Linux architecture: $raw_arch"; exit 1 ;;
esac
log_info "Fetching latest Hyperfine version from GitHub..."
local latest_tag=""
latest_tag=$(github_get_latest_release "sharkdp/hyperfine")
if [ -z "$latest_tag" ]; then
latest_tag="v1.20.0"
log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag"
else
log_info "Latest Hyperfine version found: $latest_tag"
fi
local archive="$TMP_DIR/hyperfine.tar.gz"
github_download_asset "sharkdp/hyperfine" "$latest_tag" "hyperfine-${latest_tag}-${arch}-unknown-linux-gnu.tar.gz" "$archive"
# Extract the archive
log_info "Extracting Hyperfine archive..."
tar -xzf "$archive" -C "$TMP_DIR"
local extract_dir="$TMP_DIR/hyperfine-${latest_tag}-${arch}-unknown-linux-gnu"
if [ ! -d "$extract_dir" ]; then
# Handle case where directory name might differ
extract_dir=$(find "$TMP_DIR" -maxdepth 1 -type d -name "hyperfine-*" | head -n1)
fi
if [ -z "$extract_dir" ] || [ ! -d "$extract_dir" ]; then
log_error "Failed to locate extracted Hyperfine directory."
exit 1
fi
# Install binary to $BOOTSTRAP_BIN
local target_dir="$BOOTSTRAP_BIN"
mkdir -p "$target_dir"
log_info "Installing Hyperfine to $target_dir/hyperfine..."
cp "$extract_dir/hyperfine" "$target_dir/hyperfine"
chmod +x "$target_dir/hyperfine"
track_file "$target_dir/hyperfine"
# Install man page if present
if [ -f "$extract_dir/hyperfine.1" ]; then
local man_dir="$HOME/.local/share/man/man1"
mkdir -p "$man_dir"
log_info "Installing man page to $man_dir/hyperfine.1..."
cp "$extract_dir/hyperfine.1" "$man_dir/hyperfine.1"
track_file "$man_dir/hyperfine.1"
fi
# Install autocomplete if present
if [ -f "$extract_dir/autocomplete/hyperfine.bash" ]; then
log_info "Installing bash completions..."
local comp_content
comp_content=$(cat "$extract_dir/autocomplete/hyperfine.bash")
write_completion_snippet "hyperfine" "$comp_content"
fi
register_tool "hyperfine" "binary" "$latest_tag" "github:sharkdp/hyperfine"
}
main() {
install_hyperfine
echo
log_success "Hyperfine installation complete."
}
main "$@"

72
installers/install_lazygit.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/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
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
@@ -29,12 +24,13 @@ install_nvm() {
if ! has_command tar; then
log_info "tar not found. Installing tar..."
pkg_install tar
registry_add_sys_deps "node" "tar"
fi
# 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 +41,37 @@ 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"
# shellcheck source=/dev/null
. "$BOOTSTRAP_RUNTIMES/nvm/nvm.sh"
else
log_error "Could not load NVM to install Node.js."
return 1
@@ -99,6 +87,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 +99,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 || grep '"version":' "$BOOTSTRAP_RUNTIMES/nvm/package.json" | 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,18 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# 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 +30,18 @@ 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" \
"arch:gcc|debian:build-essential|fedora:gcc" \
"arch:python|debian:python3|fedora:python3" \
"debian:python3-pip|fedora:python3-pip" \
"debian:python3-venv" \
"fedora:gcc-c++"
registry_add_sys_deps "nvim" \
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 +52,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 +84,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 +114,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

@@ -1,26 +1,20 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# 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 +49,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 +64,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 +76,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

@@ -1,17 +1,13 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# 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 +21,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 +35,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

@@ -1,17 +1,13 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# 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 +24,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 +44,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
@@ -43,6 +38,7 @@ install_yay() {
else
log_info "Dependencies (git and base-devel) are already present. Skipping package installation."
fi
registry_add_sys_deps "yay" "git" "base-devel"
log_info "Cloning yay-bin repository..."
local clone_dir
@@ -61,10 +57,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,69 @@ 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
registry_add_sys_deps "yazi" "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

@@ -1,25 +1,15 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# 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
@@ -29,6 +19,7 @@ install_fzf() {
log_info "fzf not found. Installing fzf..."
pkg_install fzf
registry_add_sys_deps "zoxide" "fzf"
}
install_zoxide() {
@@ -36,29 +27,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
@@ -72,6 +81,7 @@ make_temp_dir() {
version_lt() {
[ "$1" = "$2" ] && return 1
local IFS=.
# shellcheck disable=SC2206
local i ver1=($1) ver2=($2)
for ((i=${#ver1[@]}; i<3; i++)); do ver1[i]=0; done
for ((i=${#ver2[@]}; i<3; i++)); do ver2[i]=0; done
@@ -84,9 +94,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

@@ -11,6 +11,7 @@ if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
# Assumes common.sh is in the same directory as platform.sh
# We resolve the directory of the current script
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
. "$_LIB_DIR/common.sh"
fi
@@ -36,18 +37,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 +61,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
parse_plugin_manifest < "$temp_file" >> "$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=$(tr -d '[:space:]' < "$BOOTSTRAP_DIR/VERSION")
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

@@ -1,10 +1,16 @@
# shellcheck shell=bash
# shellcheck disable=SC2034
# This file is auto-generated by scripts/generate_registry.sh. Do not edit manually.
declare -A INSTALLERS=(
[agy]="Antigravity CLI"
[asciicinema]="asciinema terminal recorder"
[bat]="Bat (alternative to cat) and configure alias"
[docker]="Container runtime and orchestration platform"
[hyperfine]="Command-line benchmarking tool"
[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 +22,11 @@ declare -A INSTALLERS=(
declare -A INSTALLER_DISPLAYS=(
[agy]="Antigravity"
[asciicinema]="asciicinema"
[bat]="Bat"
[docker]="Docker"
[hyperfine]="Hyperfine"
[lazygit]="lazygit"
[node]="Node"
[nvim]="Neovim"
[pnpm]="Pnpm"
@@ -28,4 +38,22 @@ 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"
[hyperfine]="binary"
[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 hyperfine 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"
@@ -146,6 +216,14 @@ for script in "${SCRIPTS[@]}"; do
exit 1
fi
;;
me)
run_plugin "auth" "me" "$@"
exit $?
;;
trust)
run_plugin "auth" "trust" "$@"
exit $?
;;
con)
if [ -f "$BOOTSTRAP_DIR/commands/con.sh" ]; then
. "$BOOTSTRAP_DIR/commands/con.sh" "$@"
@@ -191,10 +269,46 @@ for script in "${SCRIPTS[@]}"; do
exit 1
fi
;;
fall)
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)
target="${1:-}"
if [ -z "$target" ]; then
rollback_bare
else
# Split comma-separated targets
IFS=',' read -ra TARGETS <<< "$target"
if [ ${#TARGETS[@]} -gt 1 ]; then
for t in "${TARGETS[@]}"; do
uninstall_tool "$t"
done
else
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
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

@@ -9,6 +9,7 @@ _LIB_SHELL_CONFIG_SOURCED=1
# Source common utilities if not already loaded
if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
. "$_LIB_DIR/common.sh"
fi
@@ -102,6 +103,90 @@ 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
}
# Write completion snippet to completions.d/
# Usage: write_completion_snippet <name> <content>
write_completion_snippet() {
local name="$1"
local content="$2"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/completions.d"
mkdir -p "$dir"
log_info "Writing completion 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 completion snippet from completions.d/
# Usage: remove_completion_snippet <name>
remove_completion_snippet() {
local name="$1"
local dir="${BOOTSTRAP_DIR:-$HOME/.config/bootstrap}/completions.d"
if [ -f "$dir/${name}.sh" ]; then
log_info "Removing completion 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 +195,15 @@ create_fd_symlink() {
fi
}
# Source the bashrc file to reload configurations
source_bashrc() {
if [ -f "$HOME/.bashrc" ]; then
log_info "Re-sourcing ~/.bashrc..."
# shellcheck source=/dev/null
. "$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 write_completion_snippet remove_completion_snippet source_bashrc

28
plugins.json Normal file
View File

@@ -0,0 +1,28 @@
{
"plugins": {
"auth": {
"version": "1.0.0",
"url": "https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/plugins/auth.sh",
"bootstrap": "2.2.0",
"description": "Client Authentication and Provisioning Plugin"
},
"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"
}
}
}

242
plugins/auth.sh Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
#
# Authentication & Provisioning Plugin for Bootstrap CLI
# Handles requester (b me) and approver (b trust) flows.
#
set -euo pipefail
# Ensure dependencies are met
pkg_install "arch:openssh|debian:openssh-client|fedora:openssh-clients" "curl" "jq" "age"
# Ensure public key exists next to private key for ssh-keygen -Y sign
ensure_pubkey_exists() {
local priv_key="$1"
local pub_key="${priv_key}.pub"
if [ ! -f "$pub_key" ]; then
ssh-keygen -y -f "$priv_key" > "$pub_key"
fi
}
COMMAND="${1:-}"
if [ -z "$COMMAND" ]; then
echo "Usage: b auth <me|trust> [args...]" >&2
exit 1
fi
shift
# Defaults
SERVER_URL="https://b.adityagupta.dev/auth"
KEY_DIR="$HOME/.config/bootstrap-client"
POLL_INTERVAL=5
ADMIN_KEY="$HOME/.ssh/id_ed25519"
USER_CODE=""
if [ "$COMMAND" = "trust" ]; then
if [ $# -lt 1 ]; then
log_error "user_code is required for trust."
echo "Usage: b trust <user_code> [--server <server_url>] [--admin-key <path>]" >&2
exit 1
fi
USER_CODE="$1"
shift
fi
# Parse remaining arguments
while [ $# -gt 0 ]; do
case "$1" in
--server)
SERVER_URL="$2"
shift 2
;;
--key-dir)
KEY_DIR="$2"
shift 2
;;
--poll-interval)
POLL_INTERVAL="$2"
shift 2
;;
--admin-key)
ADMIN_KEY="$2"
shift 2
;;
*)
log_error "Unknown argument: $1"
exit 1
;;
esac
done
if [ "$COMMAND" = "me" ]; then
mkdir -p "$KEY_DIR"
local_key="$KEY_DIR/id_ed25519"
if [ ! -f "$local_key" ]; then
log_info "Generating local Ed25519 key pair under $KEY_DIR..."
ssh-keygen -t ed25519 -N "" -f "$local_key" >/dev/null
fi
ensure_pubkey_exists "$local_key"
pub_key=$(cat "${local_key}.pub")
hostname=$(hostname 2>/dev/null || uname -n)
os=$(uname -s 2>/dev/null || echo "linux")
# Safely construct JSON payload
json_payload=$(jq -n \
--arg hn "$hostname" \
--arg os "$os" \
--arg pk "$pub_key" \
'{hostname: $hn, os: $os, public_key: $pk}')
log_info "Registering device with $SERVER_URL..."
register_response=$(curl -fsSL -X POST \
-H "Content-Type: application/json" \
-d "$json_payload" \
"$SERVER_URL/api/register")
user_code=$(echo "$register_response" | jq -r '.user_code // empty')
challenge_nonce=$(echo "$register_response" | jq -r '.challenge_nonce // empty')
if [ -z "$user_code" ] || [ -z "$challenge_nonce" ]; then
log_error "Failed to retrieve registration codes from server response."
exit 1
fi
echo "--------------------------------------------------------"
log_success "Device registration initiated successfully!"
echo "Please authorize this device on your administrator machine using:"
echo " b trust $user_code --server $SERVER_URL"
echo "--------------------------------------------------------"
echo "Verification Code: $user_code"
echo "--------------------------------------------------------"
log_info "Waiting for administrator approval (polling every ${POLL_INTERVAL}s)..."
# Prepare challenge poll file signing
temp_nonce_file=$(mktemp)
temp_sig_file="${temp_nonce_file}.sig"
echo -n "$challenge_nonce" > "$temp_nonce_file"
# Ensure cleanup of temp files
cleanup() {
rm -f "$temp_nonce_file" "$temp_sig_file"
}
trap cleanup EXIT INT TERM
while true; do
rm -f "$temp_sig_file"
# Sign challenge nonce
if ! ssh-keygen -Y sign -f "$local_key" -n "bootstrap" "$temp_nonce_file" >/dev/null 2>&1; then
log_error "Cryptographic signing of challenge nonce failed."
exit 1
fi
# Get raw base64 from armored signature file
signature_b64=$(grep -v '^-' "$temp_sig_file" | tr -d '\n')
poll_payload=$(jq -n \
--arg uc "$user_code" \
--arg sig "$signature_b64" \
'{user_code: $uc, signature: $sig}')
poll_out=$(mktemp)
http_code=$(curl -s -o "$poll_out" -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$poll_payload" \
"$SERVER_URL/api/challenge/poll")
poll_body=$(cat "$poll_out")
rm -f "$poll_out"
if [ "$http_code" = "200" ]; then
enc_secrets=$(echo "$poll_body" | jq -r '.encrypted_secrets // empty')
if [ -n "$enc_secrets" ] && [ "$enc_secrets" != "null" ]; then
log_success "Device approved by administrator! Decrypting secrets payload..."
decrypted_file="$KEY_DIR/secrets.decrypted"
if echo "$enc_secrets" | base64 -d | age --decrypt -i "$local_key" > "$decrypted_file" 2>/dev/null; then
log_success "Secrets successfully provisioned and written to: $decrypted_file"
cat "$decrypted_file"
break
else
log_error "Decryption using age failed. Please ensure the private key has not been altered."
exit 1
fi
fi
fi
sleep "$POLL_INTERVAL"
done
elif [ "$COMMAND" = "trust" ]; then
if [ ! -f "$ADMIN_KEY" ]; then
log_error "Admin private key not found at: $ADMIN_KEY"
exit 1
fi
ensure_pubkey_exists "$ADMIN_KEY"
log_info "Fetching pending device details for user code: $USER_CODE"
pending_response=$(curl -fsSL "$SERVER_URL/api/pending/$USER_CODE")
requester_pub_key=$(echo "$pending_response" | jq -r '.public_key // empty')
if [ -z "$requester_pub_key" ]; then
log_error "No pending registration found for code '$USER_CODE'."
exit 1
fi
echo "--------------------------------------------------------"
echo "Pending Device Public Key:"
echo "$requester_pub_key"
echo "--------------------------------------------------------"
# Prompt for confirmation (read from tty to support pipeline scenarios)
read -r -p "Do you trust and approve this device? [y/N]: " confirm_choice </dev/tty || confirm_choice="N"
if [[ ! "$confirm_choice" =~ ^[Yy]$ ]]; then
log_warn "Approval aborted."
exit 0
fi
# Generate signature of the requester's public key
temp_pubkey_file=$(mktemp)
temp_pubkey_sig_file="${temp_pubkey_file}.sig"
echo -n "$requester_pub_key" > "$temp_pubkey_file"
# Cleanup trap
cleanup_trust() {
rm -f "$temp_pubkey_file" "$temp_pubkey_sig_file"
}
trap cleanup_trust EXIT INT TERM
if ! ssh-keygen -Y sign -f "$ADMIN_KEY" -n "bootstrap" "$temp_pubkey_file" >/dev/null 2>&1; then
log_error "Cryptographic signing using administrator key failed."
exit 1
fi
signature_b64=$(grep -v '^-' "$temp_pubkey_sig_file" | tr -d '\n')
# Get fingerprint
admin_pubkey_str=$(ssh-keygen -y -f "$ADMIN_KEY")
temp_admin_pub=$(mktemp)
echo "$admin_pubkey_str" > "$temp_admin_pub"
approver_fingerprint=$(ssh-keygen -lf "$temp_admin_pub" | awk '{print $2}')
rm -f "$temp_admin_pub"
# Prepare payload
approve_payload=$(jq -n \
--arg uc "$USER_CODE" \
--arg fp "$approver_fingerprint" \
--arg sig "$signature_b64" \
'{user_code: $uc, approver_public_key_fingerprint: $fp, signature: $sig}')
log_info "Submitting cryptographic approval to server..."
curl -fsSL -X POST \
-H "Content-Type: application/json" \
-d "$approve_payload" \
"$SERVER_URL/api/approve"
log_success "Device with code $USER_CODE has been approved."
fi

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)
true > "$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 "$@"

124
readme.md
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,38 @@ 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 robust rollback and uninstallation system driven by procedural manifests and a centralized JSON registry.
To safely uninstall the very last tool you installed (including wiping its shell paths and aliases):
```bash
b rb
```
To uninstall specific tools by name (supporting single or comma-separated lists of tools):
```bash
b rb nvim
b rb bat,yazi,zoxide
```
Shared system package dependencies are reference-counted in the registry and will be automatically cleaned up only when the last tool depending on them is removed.
To create a named savepoint before experimenting with your setup:
```bash
b fall pre_dev_setup
```
*(Note: Savepoint names cannot conflict with the names of any available tools).*
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 +119,82 @@ b up
b up --force
```
### Client Authentication and Provisioning (`b me` and `b trust`)
Bootstrap CLI supports a secure, cryptographic client onboarding and secrets provisioning flow. This allows you to securely register new requester devices and authorize them via an administrator device.
For the complete protocol specification, including the underlying REST API and cryptographic details, see the [Client Authentication and Provisioning Specification](docs/client_spec_auth.md).
#### 1. Device Registration (`b me`)
To initiate registration on a new, unprovisioned machine, run:
```bash
b me [--server <server_url>] [--key-dir <dir>] [--poll-interval <seconds>]
```
This generates a local SSH Ed25519 key pair, registers your device in a pending state with the authentication server, displays a short `user_code`, and starts polling for administrator approval. Once approved, the encrypted payload containing secrets is retrieved, decrypted locally via `age`, and saved to `<key-dir>/secrets.decrypted`.
#### 2. Request Approval (`b trust`)
To authorize a pending device, run this command on an already provisioned administrator machine:
```bash
b trust <user_code> [--server <server_url>] [--admin-key <path_to_admin_private_key>]
```
This retrieves the pending device's public key, prompts the administrator for confirmation, cryptographically signs the public key using the admin key, and submits the approval signature back to the authentication server.
## 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:
@@ -102,6 +211,20 @@ b gone -f
Then reload your shell configuration or run `unset -f b` to clear the function definition from your current terminal session.
## Directory Layout
Bootstrap isolates all of its components and installed software inside XDG-compliant directories. You can override these variables in your shell environment, but they default to the following:
| Variable | Default | Purpose |
| :--- | :--- | :--- |
| `BOOTSTRAP_DIR` | `~/.config/bootstrap` | Core configuration, libraries, shell snippets (`env.d`, `aliases.d`, `completions.d`), and local installers |
| `BOOTSTRAP_DATA_DIR` | `~/.local/share/bootstrap` | Active installation bin files, optional components, and runtime installations |
| `BOOTSTRAP_STATE_DIR` | `~/.local/state/bootstrap` | Stateful registry, logs, and rollback/uninstall manifests |
| `BOOTSTRAP_CACHE_DIR` | `~/.cache/bootstrap` | Downloaded files cache and temporary workspace files |
| `BOOTSTRAP_BIN` | `$BOOTSTRAP_DATA_DIR/bin` | Target directory for all installed tools' binaries |
| `BOOTSTRAP_OPT` | `$BOOTSTRAP_DATA_DIR/opt` | Destination for complex, multi-file software distributions |
| `BOOTSTRAP_RUNTIMES` | `$BOOTSTRAP_DATA_DIR/runtimes` | Workspace for programming language runtime managers (e.g. NVM) |
## Development
If you are developing this tool locally:
@@ -126,7 +249,6 @@ The scripts are intentionally straightforward Bash scripts that can be inspected
Potential additions include:
* RSA based authentication
* Development environment bootstrap
* Workstation setup
* Server provisioning

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,35 +21,46 @@ 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
# Sort keys alphabetically
sorted_keys=($(printf '%s\n' "${keys[@]}" | sort))
mapfile -t sorted_keys < <(printf '%s\n' "${keys[@]}" | sort)
{
echo "# shellcheck shell=bash"
echo "# shellcheck disable=SC2034"
echo "# This file is auto-generated by scripts/generate_registry.sh. Do not edit manually."
echo ""
echo "declare -A INSTALLERS=("
for k in "${sorted_keys[@]}"; do
# Escape any double quotes in description
escaped_desc=$(echo "${tools_desc[$k]}" | sed 's/"/\\"/g')
escaped_desc="${tools_desc[$k]//\"/\\\"}"
echo " [$k]=\"$escaped_desc\""
done
echo ")"
echo ""
echo "declare -A INSTALLER_DISPLAYS=("
for k in "${sorted_keys[@]}"; do
escaped_disp=$(echo "${tools_disp[$k]}" | sed 's/"/\\"/g')
escaped_disp="${tools_disp[$k]//\"/\\\"}"
echo " [$k]=\"$escaped_disp\""
done
echo ")"
echo ""
echo "declare -A INSTALLER_STRATEGIES=("
for k in "${sorted_keys[@]}"; do
escaped_strat="${tools_strat[$k]//\"/\\\"}"
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."