22 Commits

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

View File

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

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

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

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

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

View File

@@ -1 +1 @@
2.0.0
2.2.0

9
b.sh
View File

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

View File

@@ -41,7 +41,7 @@ else
BOOTSTRAP_SOURCE_DIR="$BOOTSTRAP_TMP_DIR"
_BASE_URL="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master"
_LIBS=("lib/common.sh" "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh")
_LIBS=("lib/common.sh" "lib/rollback.sh" "lib/platform.sh" "lib/shell_config.sh" "lib/json.sh" "lib/plugins.sh")
_curl_args=()
for _lib in "${_LIBS[@]}"; do
@@ -71,6 +71,7 @@ install_bootstrap() {
mkdir -p "$routes_dir"
mkdir -p "$routes_dir/env.d"
mkdir -p "$routes_dir/aliases.d"
mkdir -p "$routes_dir/completions.d"
# List of all files to download/copy
local files=(
@@ -82,6 +83,8 @@ install_bootstrap() {
"lib/rollback.sh"
"lib/platform.sh"
"lib/shell_config.sh"
"lib/json.sh"
"lib/plugins.sh"
"commands/help.sh"
"commands/con.sh"
"commands/uninstall.sh"
@@ -140,6 +143,7 @@ export BOOTSTRAP_DIR="$HOME/.config/bootstrap"
[ -f "$BOOTSTRAP_DIR/b.sh" ] && . "$BOOTSTRAP_DIR/b.sh"
for f in "$BOOTSTRAP_DIR/env.d/"*.sh; do [ -r "$f" ] && . "$f"; done
for f in "$BOOTSTRAP_DIR/aliases.d/"*.sh; do [ -r "$f" ] && . "$f"; done
for f in "$BOOTSTRAP_DIR/completions.d/"*.sh; do [ -r "$f" ] && . "$f"; done
# <<< bootstrap-cli setup <<<
EOF

View File

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

162
docs/client_spec_auth.md Normal file
View File

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

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ install_agy() {
fi
log_info "Downloading release package..."
curl -fsSL "$url" -o "$staging_payload"
download_file "$url" "$staging_payload"
# Verify SHA512 Checksum
local actual_hash

View File

@@ -76,7 +76,7 @@ install_asciicinema() {
local download_url="https://github.com/asciinema/asciinema/releases/download/${latest_tag}/asciinema-${asciinema_arch}"
log_info "Downloading asciinema ${latest_tag} for ${arch}..."
curl -fsSL "$download_url" -o "$TMP_DIR/asciinema"
download_file "$download_url" "$TMP_DIR/asciinema"
log_info "Installing asciinema to /usr/local/bin..."
sudo cp "$TMP_DIR/asciinema" /usr/local/bin/asciinema

View File

@@ -63,7 +63,7 @@ install_bat() {
local deb_url="https://github.com/sharkdp/bat/releases/download/${latest_tag}/bat_${version}_${deb_arch}.deb"
log_info "Downloading Bat from ${deb_url}..."
curl -fsSL "$deb_url" -o "$TMP_DIR/bat.deb"
download_file "$deb_url" "$TMP_DIR/bat.deb"
log_info "Installing Bat package..."
sudo apt install -y "$TMP_DIR/bat.deb"
@@ -94,7 +94,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,72 @@
#!/usr/bin/env bash
# Tool: docker
# DisplayName: Docker
# Description: Container runtime and orchestration platform
#
# Docker Installer Script
#
# Prevent standalone execution
if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
echo "Error: This script must be run through the 'b' CLI." >&2
exit 1
fi
set -euo pipefail
# ─── Installation Logic ──────────────────────────────────────────────
install_docker() {
if has_command docker; then
if ! confirm "Docker is already installed. Reinstall/Upgrade?"; then
log_info "Skipping Docker installation."
return
fi
else
if ! confirm "Install Docker?"; then
log_info "Skipping Docker installation."
return
fi
fi
# Use pkg_install for distro packages (it automatically handles rollback hooks for the packages!)
pkg_install "arch:docker|debian:docker.io|fedora:docker"
# Ensure docker group exists (some distros might not create it immediately)
if ! getent group docker >/dev/null 2>&1; then
log_info "Creating docker group..."
add_rollback_cmd "sudo groupdel docker || true"
sudo groupadd docker
fi
# Configure user group
if ! groups "$USER" | grep -q "\bdocker\b"; then
log_info "Adding $USER to the docker group..."
add_rollback_cmd "sudo gpasswd -d $USER docker || true"
sudo usermod -aG docker "$USER"
log_warn "You will need to log out and log back in, or run 'newgrp docker' for the group changes to take effect."
fi
# Enable and start systemd services
if has_command systemctl; then
log_info "Enabling and starting Docker services..."
# Add rollback cmds for systemd
add_rollback_cmd "sudo systemctl disable --now docker.service || true"
add_rollback_cmd "sudo systemctl disable --now containerd.service || true"
sudo systemctl enable --now docker.service || true
sudo systemctl enable --now containerd.service || true
fi
}
# ─── Main ─────────────────────────────────────────────────────────────
main() {
install_docker
echo
log_success "Docker installation and configuration complete."
}
main "$@"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Tool: hyperfine
# DisplayName: Hyperfine
# Description: Command-line benchmarking tool
#
# Hyperfine Installer Script
#
# Prevent standalone execution
if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
echo "Error: This script must be run through the 'b' CLI." >&2
exit 1
fi
set -euo pipefail
TMP_DIR="$(make_temp_dir)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
install_hyperfine() {
if has_command hyperfine || [ -f "$HOME/.local/bin/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
# 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)
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=$(curl -sL https://api.github.com/repos/sharkdp/hyperfine/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 Hyperfine version found: $latest_tag"
download_url="https://github.com/sharkdp/hyperfine/releases/download/${latest_tag}/hyperfine-${latest_tag}-${arch}-unknown-linux-gnu.tar.gz"
else
latest_tag="v1.20.0"
log_warn "Failed to fetch latest version from GitHub. Falling back to: $latest_tag"
download_url="https://github.com/sharkdp/hyperfine/releases/download/${latest_tag}/hyperfine-${latest_tag}-${arch}-unknown-linux-gnu.tar.gz"
fi
log_info "Downloading Hyperfine from ${download_url}..."
local archive="$TMP_DIR/hyperfine.tar.gz"
download_file "$download_url" "$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 (e.g. without leading v in directory name or tag)
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 ~/.local/bin
local target_dir="$HOME/.local/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
}
configure_shell() {
# Add ~/.local/bin to PATH for the current process
export PATH="$HOME/.local/bin:$PATH"
# Clean up legacy in-place configuration blocks
IFS=' ' read -ra target_files <<< "$(get_shell_configs)"
for config_file in "${target_files[@]}"; do
remove_block "$config_file" "local-bin path"
done
write_env_snippet "local-bin" 'export PATH="$HOME/.local/bin:$PATH"'
}
main() {
install_hyperfine
configure_shell
echo
log_success "Hyperfine installation and configuration complete."
}
main "$@"

81
installers/install_lazygit.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Tool: lazygit
# DisplayName: lazygit
# Description: Simple terminal UI for git commands
#
# lazygit Installer Script
#
# Prevent standalone execution
if [ -z "${_LIB_COMMON_SOURCED:-}" ]; then
echo "Error: This script must be run through the 'b' CLI." >&2
exit 1
fi
set -euo pipefail
# ─── Installation Logic ──────────────────────────────────────────────
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=""
if has_command curl; then
latest_tag=$(curl -sL https://api.github.com/repos/jesseduffield/lazygit/releases/latest \
| grep '"tag_name":' | head -n1 \
| sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true)
fi
if [ -z "$latest_tag" ]; then
latest_tag="v0.62.2" # fallback
log_warn "Failed to fetch latest version. Falling back to: $latest_tag"
fi
local version="${latest_tag#v}"
local arch=$(detect_arch)
local arch_str="x86_64"
if [ "$arch" = "arm64" ]; then
arch_str="arm64"
fi
local url="https://github.com/jesseduffield/lazygit/releases/download/${latest_tag}/lazygit_${version}_linux_${arch_str}.tar.gz"
TMP_DIR="$(make_temp_dir)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
local dest="$TMP_DIR/lazygit.tar.gz"
log_info "Downloading lazygit ${latest_tag}..."
download_file "$url" "$dest"
log_info "Extracting..."
tar -xzf "$dest" -C "$TMP_DIR"
mkdir -p "$HOME/.local/bin"
cp "$TMP_DIR/lazygit" "$HOME/.local/bin/lazygit"
chmod +x "$HOME/.local/bin/lazygit"
track_file "$HOME/.local/bin/lazygit"
}
# ─── Main ─────────────────────────────────────────────────────────────
main() {
install_lazygit
echo
log_success "lazygit installation complete."
}
main "$@"

View File

@@ -45,8 +45,7 @@ install_nvm() {
local nvm_url="https://github.com/nvm-sh/nvm/archive/refs/tags/${latest_tag}.tar.gz"
log_info "Downloading NVM from $nvm_url..."
curl -fsSL "$nvm_url" -o "$TMP_DIR/nvm.tar.gz"
download_file "$nvm_url" "$TMP_DIR/nvm.tar.gz"
log_info "Extracting NVM archive directly to $HOME/.nvm (stripping versioned subfolder to keep config generic)..."
mkdir -p "$HOME/.nvm"
@@ -110,7 +109,6 @@ main() {
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")"
else
log_success "Installation complete."
log_info "Please close and reopen your terminal or run: source ~/.bashrc to verify."
fi
}

View File

@@ -79,7 +79,7 @@ install_nvim() {
local nvim_url="https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-${nvim_arch}.tar.gz"
log_info "Downloading Neovim v${NVIM_VERSION} for ${arch}..."
curl -fsSL "$nvim_url" -o "$TMP_DIR/nvim.tar.gz"
download_file "$nvim_url" "$TMP_DIR/nvim.tar.gz"
tar -xzf "$TMP_DIR/nvim.tar.gz" -C "$TMP_DIR"

View File

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

@@ -76,7 +76,7 @@ install_rust() {
local dest="$TMP_DIR/rustup-init"
log_info "Downloading rustup-init..."
curl -fsSL "$url" -o "$dest"
download_file "$url" "$dest"
chmod +x "$dest"

View File

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

View File

@@ -68,7 +68,7 @@ install_uv() {
log_info "Downloading uv from ${download_url}..."
local archive="$TMP_DIR/uv.tar.gz"
curl -fsSL "$download_url" -o "$archive"
download_file "$download_url" "$archive"
# Extract the binaries
log_info "Extracting uv binaries..."

View File

@@ -76,7 +76,7 @@ install_yazi() {
local deb_url="https://github.com/sxyazi/yazi/releases/download/${latest_tag}/yazi-x86_64-unknown-linux-gnu.deb"
log_info "Downloading Yazi ${latest_tag} from ${deb_url}..."
curl -fsSL "$deb_url" -o "$TMP_DIR/yazi.deb"
download_file "$deb_url" "$TMP_DIR/yazi.deb"
log_info "Installing Yazi package..."
sudo apt install -y "$TMP_DIR/yazi.deb"

View File

@@ -84,9 +84,89 @@ version_lt() {
done
return 1
}
# Cached and resumable download helper
download_file() {
local url="$1"
local dest="$2"
local cache_dir="$HOME/.local/state/bootstrap/cache"
mkdir -p "$cache_dir"
local safe_name
if has_command md5sum; then
safe_name=$(echo -n "$url" | md5sum | cut -d' ' -f1)
elif has_command shasum; then
safe_name=$(echo -n "$url" | shasum | cut -d' ' -f1)
else
safe_name=$(echo -n "$url" | tr -c '[:alnum:]_.-' '_')
fi
local base_name
base_name=$(basename "$url")
local cache_file="$cache_dir/${safe_name}_${base_name}"
log_info "Downloading $base_name (resumable)..."
if ! curl -fL -C - "$url" -o "$cache_file"; then
local exit_code=$?
# Exit code 33: HTTP server doesn't support ranges/resuming
# Exit code 36: Bad download resume offset
if [ $exit_code -eq 33 ] || [ $exit_code -eq 36 ]; then
log_warn "Server does not support resuming. Retrying from scratch..."
rm -f "$cache_file"
curl -fL "$url" -o "$cache_file" || return 1
else
return $exit_code
fi
fi
mkdir -p "$(dirname "$dest")"
cp "$cache_file" "$dest"
}
# 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

68
lib/json.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# generic JSON parser in pure bash and awk.
# reads JSON from stdin and outputs a flattened list of key-value pairs.
# example input: {"plugins": {"my_plugin": {"version": "1.0", "arr": [1, 2]}}}
# example output:
# plugins.my_plugin.version="1.0"
# plugins.my_plugin.arr[0]=1
# plugins.my_plugin.arr[1]=2
# pardon my french
parse_json() {
# Tokenize the JSON using grep
grep -oE '"([^"\\]|\\.)*"|true|false|null|[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?|[][}{:,]' | \
awk '
BEGIN {
depth=0;
key=""
}
{
token = $0
if (token == "{") {
depth++
is_key[depth] = 1
array_idx[depth] = ""
} else if (token == "}") {
delete path[depth]
delete array_idx[depth]
depth--
} else if (token == "[") {
depth++
is_key[depth] = 0
array_idx[depth] = 0
} else if (token == "]") {
delete array_idx[depth]
delete path[depth]
depth--
} else if (token == ":") {
is_key[depth] = 0
} else if (token == ",") {
if (array_idx[depth] != "") {
array_idx[depth]++
} else {
is_key[depth] = 1
}
} else {
if (is_key[depth] == 1) {
# Remove quotes from the key
gsub(/^"|"$/, "", token)
path[depth] = token
} else {
# It is a value
p = ""
for (i=1; i<=depth; i++) {
if (array_idx[i] != "") {
p = p "[" array_idx[i] "]"
} else if (path[i] != "") {
p = p "." path[i]
}
}
# Remove leading dot
sub(/^\./, "", p)
print p "=" token
}
}
}
'
}

217
lib/plugins.sh Normal file
View File

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

View File

@@ -4,6 +4,9 @@ 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.12.0 and configuration"
[pnpm]="pnpm package manager"
@@ -19,6 +22,9 @@ declare -A INSTALLER_DISPLAYS=(
[agy]="Antigravity"
[asciicinema]="asciicinema"
[bat]="Bat"
[docker]="Docker"
[hyperfine]="Hyperfine"
[lazygit]="lazygit"
[node]="Node"
[nvim]="Neovim"
[pnpm]="Pnpm"
@@ -30,4 +36,4 @@ declare -A INSTALLER_DISPLAYS=(
[zoxide]="Zoxide"
)
INSTALLER_KEYS=(agy asciicinema bat node nvim pnpm rust starship uv yay yazi zoxide)
INSTALLER_KEYS=(agy asciicinema bat docker hyperfine lazygit node nvim pnpm rust starship uv yay yazi zoxide)

View File

@@ -20,8 +20,16 @@ setup_uninstaller_context() {
local tool="$1"
export BOOTSTRAP_CURRENT_TOOL="$tool"
export BOOTSTRAP_UNINSTALLER_CMDS="$BOOTSTRAP_UNINSTALLERS_DIR/${tool}.cmds"
# Ensure fresh manifest for this run
rm -f "$BOOTSTRAP_UNINSTALLER_CMDS"
# 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"
}

View File

@@ -35,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"
@@ -114,11 +126,55 @@ 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=$?
if [ "$run_status" -eq 0 ]; then
# 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
@@ -147,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"
@@ -219,9 +280,13 @@ for script in "${SCRIPTS[@]}"; do
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

@@ -158,6 +158,34 @@ remove_alias_snippet() {
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
@@ -166,8 +194,14 @@ create_fd_symlink() {
fi
}
# Source the bashrc file to reload configurations
source_bashrc() {
if [ -f "$HOME/.bashrc" ]; then
log_info "Re-sourcing ~/.bashrc..."
. "$HOME/.bashrc"
fi
}
# Export functions and variables for subshells
export _LIB_SHELL_CONFIG_SOURCED=1
export -f get_shell_configs remove_block inject_block add_alias_if_missing add_env_if_missing create_fd_symlink write_env_snippet write_alias_snippet remove_env_snippet remove_alias_snippet
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

22
plugins.json Normal file
View File

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

74
plugins/sysinfo.sh Normal file
View File

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

123
plugins/todo.sh Normal file
View File

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

32
plugins/weather.sh Normal file
View File

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

View File

@@ -77,6 +77,28 @@ b con i3
It automatically fuzzy-finds the folder in case there is no exact match. Also, in case there is only a singular config file in that folder, then it will directly open that file.
### Rollbacks and Savepoints (`b rb` and `b fall`)
Bootstrap CLI features a powerful, procedural rollback system. It strictly tracks every extracted binary, configuration snippet, and package manager transaction to ensure your environment stays clean.
To safely uninstall the very last tool you installed (including wiping its shell paths and aliases):
```bash
b rb
```
To create a named savepoint before experimenting with your setup:
```bash
b fall pre_dev_setup
```
To completely roll back all installations made after that savepoint, restoring your system back to that exact state:
```bash
b rb pre_dev_setup
```
### Updating
To check for updates and update the tool manually:
@@ -87,6 +109,58 @@ b up
b up --force
```
## Plugins (`b <plugin_name>`)
Plugins are first-party or third-party applications written to work directly with `bootstrap`. Unlike installers (or packages) which modify your system by compiling code, downloading binaries, and altering shell configuration files, **plugins are lazy-loaded scripts that execute within a subshell**.
Downloading and invoking a plugin makes no system modifications other than caching the `.sh` file itself. They are fetched only the very first time you invoke them.
### Official Plugins
Bootstrap comes pre-configured with a set of official plugins ready to use out-of-the-box:
* **`weather`**: Fetches and displays a neat weather forecast using `wttr.in`.
```bash
b weather
b weather New York
```
* **`sysinfo`**: Displays a system resource dashboard showing CPU, memory, disk usage, and OS/kernel details.
```bash
b sysinfo
```
* **`todo`**: A command-line todo list manager. Tasks are persisted to a lightweight text file in your user directory.
```bash
b todo add "Write a new plugin"
b todo list
b todo done 1
```
### Adding Third-Party Plugins
To manage plugin repositories, run:
```bash
b plugin sources
```
This opens a configuration file in your `$EDITOR`. You can add raw URLs pointing to JSON plugin manifests from any repository. Once you close the editor, `bootstrap` automatically parses those manifests using its native JSON parser and generates a fast, zero-latency lookup cache.
You can then execute any plugin simply by calling its name:
```bash
b my_plugin
```
Plugins are automatically checked for updates and lazily re-downloaded whenever you run `b up`.
If you prefer to run a plugin strictly in **ephemeral mode** (meaning it will bypass the cache and execute directly in memory to guarantee the absolute latest version without leaving any footprint), simply pass the `-e` or `--ephemeral` flag:
```bash
b my_plugin -e
```
For documentation on how to develop and publish your own plugins, please see the [Plugin Development Guide](docs/plugin_development.md).
## Uninstallation
To uninstall the bootstrap helper tool but leave a lightweight `b back` function to easily reinstall it later:

View File

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