This commit is contained in:
2026-06-28 08:51:25 +05:30
parent 671cf7f818
commit 190f337f12
34 changed files with 650 additions and 648 deletions

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
@@ -88,7 +98,7 @@ version_lt() {
download_file() {
local url="$1"
local dest="$2"
local cache_dir="$HOME/.local/state/bootstrap/cache"
local cache_dir="$BOOTSTRAP_CACHE_DIR/downloads"
mkdir -p "$cache_dir"

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

@@ -1,68 +0,0 @@
#!/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
}
}
}
'
}

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
@@ -86,18 +87,7 @@ pkg_install() {
if ! pkg_check "$pkg"; then
to_install+=("$pkg")
fi
# Reference counting logic
if [ -n "${BOOTSTRAP_CURRENT_TOOL:-}" ] && [ -n "${BOOTSTRAP_PACKAGES_DIR:-}" ]; then
local ref_file="$BOOTSTRAP_PACKAGES_DIR/$pkg"
if ! grep -q "^${BOOTSTRAP_CURRENT_TOOL}$" "$ref_file" 2>/dev/null; then
echo "$BOOTSTRAP_CURRENT_TOOL" >> "$ref_file"
# Register rollback command
if type add_rollback_cmd >/dev/null 2>&1; then
add_rollback_cmd "pkg_remove $pkg"
fi
fi
fi
done
if [ ${#to_install[@]} -eq 0 ]; then
@@ -169,21 +159,15 @@ pkg_remove() {
local to_remove=()
for pkg in "${pkgs[@]}"; do
if [ -n "${BOOTSTRAP_CURRENT_TOOL:-}" ] && [ -n "${BOOTSTRAP_PACKAGES_DIR:-}" ]; then
local ref_file="$BOOTSTRAP_PACKAGES_DIR/$pkg"
if [ -f "$ref_file" ]; then
# Remove this tool from the reference file
sed -i "/^${BOOTSTRAP_CURRENT_TOOL}$/d" "$ref_file"
if [ -s "$ref_file" ]; then
log_info "Skipping removal of '$pkg'; it is required by other tools."
continue
else
rm -f "$ref_file"
fi
fi
local is_installed=0
if pkg_check "$pkg"; then
is_installed=1
fi
to_remove+=("$pkg")
if [ "$is_installed" -eq 1 ]; then
to_remove+=("$pkg")
fi
done
if [ ${#to_remove[@]} -eq 0 ]; then

View File

@@ -1,50 +1,13 @@
#!/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
# Parses a plugin manifest using jq 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 "\""
}
}
}
}'
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
@@ -94,7 +57,7 @@ EOF
for temp_file in "${temp_manifests[@]}"; do
if [ -s "$temp_file" ]; then
cat "$temp_file" | parse_plugin_manifest >> "$cache_file"
parse_plugin_manifest < "$temp_file" >> "$cache_file"
fi
rm -f "$temp_file"
done
@@ -168,7 +131,7 @@ run_plugin() {
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:]')
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."

View File

@@ -1,3 +1,5 @@
# shellcheck shell=bash
# shellcheck disable=SC2034
# This file is auto-generated by scripts/generate_registry.sh. Do not edit manually.
declare -A INSTALLERS=(
@@ -36,4 +38,22 @@ declare -A INSTALLER_DISPLAYS=(
[zoxide]="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

View File

@@ -8,11 +8,11 @@ _LIB_ROLLBACK_SOURCED=1
BOOTSTRAP_STATE_DIR="$HOME/.local/state/bootstrap"
BOOTSTRAP_HISTORY_LOG="$BOOTSTRAP_STATE_DIR/history.log"
BOOTSTRAP_UNINSTALLERS_DIR="$BOOTSTRAP_STATE_DIR/uninstallers"
BOOTSTRAP_PACKAGES_DIR="$BOOTSTRAP_STATE_DIR/packages"
init_rollback_system() {
mkdir -p "$BOOTSTRAP_UNINSTALLERS_DIR"
mkdir -p "$BOOTSTRAP_PACKAGES_DIR"
touch "$BOOTSTRAP_HISTORY_LOG"
}
@@ -51,6 +51,13 @@ track_dir() {
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."
}
@@ -84,6 +91,41 @@ execute_rollback() {
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."
@@ -95,9 +137,7 @@ rollback_bare() {
if [[ "$last_line" == INSTALL:* ]]; then
local tool="${last_line#INSTALL: }"
execute_rollback "$tool"
# Remove the last line efficiently
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
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."
@@ -122,8 +162,7 @@ rollback_to_savepoint() {
break
elif [[ "$last_line" == INSTALL:* ]]; then
local tool="${last_line#INSTALL: }"
execute_rollback "$tool"
sed -i '$ d' "$BOOTSTRAP_HISTORY_LOG"
uninstall_tool "$tool"
elif [[ "$last_line" == SAVEPOINT:* ]]; then
local sp="${last_line#SAVEPOINT: }"
log_info "Removing intermediate savepoint '$sp'..."
@@ -135,4 +174,4 @@ rollback_to_savepoint() {
done
}
export -f init_rollback_system setup_uninstaller_context add_rollback_cmd track_file track_dir create_savepoint mark_install_success execute_rollback rollback_bare rollback_to_savepoint
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

@@ -262,7 +262,7 @@ for script in "${SCRIPTS[@]}"; do
fi
;;
fall)
local savepoint_name="${1:-}"
savepoint_name="${1:-}"
if [ -z "$savepoint_name" ]; then
log_error "Usage: b fall <savepoint_name>"
exit 1
@@ -271,11 +271,25 @@ for script in "${SCRIPTS[@]}"; do
exit 0
;;
rb)
local target="${1:-}"
target="${1:-}"
if [ -z "$target" ]; then
rollback_bare
else
rollback_to_savepoint "$target"
# 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
;;

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
@@ -198,6 +199,7 @@ create_fd_symlink() {
source_bashrc() {
if [ -f "$HOME/.bashrc" ]; then
log_info "Re-sourcing ~/.bashrc..."
# shellcheck source=/dev/null
. "$HOME/.bashrc"
fi
}