diff --git a/.agents/skills/add_installer/SKILL.md b/.agents/skills/add_installer/SKILL.md new file mode 100644 index 0000000..885d9b8 --- /dev/null +++ b/.agents/skills/add_installer/SKILL.md @@ -0,0 +1,261 @@ +--- +name: add_installer +description: Add a new installer script to the bootstrap CLI project. Use this skill whenever the user asks to create a new installer, add a new tool/package to bootstrap, or register a new `b ` command. +--- + +# Add a New Installer to Bootstrap CLI + +This skill provides everything needed to add a new installer to the bootstrap project without reading the entire codebase. + +## Project Overview + +Bootstrap CLI (`b`) is a bash-based tool installer and system bootstrapper. Users run `b ` to install tools (e.g., `b nvim`, `b bat`). The project lives at the workspace root. + +### Key Directories + +``` +bootstrap/ +├── installers/ # Individual installer scripts (install_.sh) +├── lib/ # Shared libraries 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() +├── commands/ # Non-installer commands (help, con, uninstall) +├── routes.sh # Central router + installer registry +├── bootstrap.sh # Metascript for environment setup + library loading +├── b.sh # The `b` shell function and autocompletion +└── VERSION +``` + +## Step-by-Step Checklist + +When adding a new installer named ``: + +### Step 1: Create the installer script + +Create `installers/install_.sh` using the template below. + +### Step 2: Register in `routes.sh` + +Make **two** edits to `routes.sh`: + +1. **Add to the `INSTALLERS` associative array** (line ~19-26). Insert a new entry in alphabetical order: + ```bash + []="Short description of what it installs" + ``` + +2. **Add to the `INSTALLER_KEYS` array** (line ~28). Insert the key in alphabetical order: + ```bash + INSTALLER_KEYS=(agy bat node nvim yazi zoxide) + ``` + +> [!IMPORTANT] +> Both arrays must be kept in sync and in alphabetical order. + +### Step 3: Verify (optional) + +Run `bash routes.sh` or `b all` to confirm the new installer appears in the help output. + +--- + +## Installer Script Template + +Every installer follows this exact boilerplate structure. Copy this and fill in the tool-specific logic: + +```bash +#!/usr/bin/env bash +# +# Installer Script +# + +# Run metascript to check if the shell is bash and load libraries +PARENT_DIR="$(dirname "$0")/.." +METASCRIPT_LOCAL="$PARENT_DIR/bootstrap.sh" +METASCRIPT_URL="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/bootstrap.sh" + +if [ -f "$METASCRIPT_LOCAL" ]; then + . "$METASCRIPT_LOCAL" +else + if command -v wget >/dev/null 2>&1; then + eval "$(wget -qO- "$METASCRIPT_URL")" + elif command -v curl >/dev/null 2>&1; then + eval "$(curl -fsSL "$METASCRIPT_URL")" + else + echo "Error: Neither wget nor curl is installed to fetch bootstrap.sh." >&2 + exit 1 + fi +fi + +set -euo pipefail + +# ─── Installation Logic ────────────────────────────────────────────── + +install_() { + if has_command ; then + if ! confirm " is already installed. Reinstall/Upgrade?"; then + log_info "Skipping installation." + return + fi + else + if ! confirm "Install ?"; then + log_info "Skipping installation." + return + fi + fi + + # --- Tool-specific installation logic goes here --- + # Use pkg_install for distro packages: + # pkg_install + # 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/wget pattern (see bat installer for reference) +} + +# ─── Shell Configuration (if needed) ───────────────────────────────── + +configure_shell() { + IFS=' ' read -ra target_files <<< "$(get_shell_configs)" + + for config_file in "${target_files[@]}"; do + log_info "Configuring in $config_file..." + + # Use inject_block to add shell init/aliases/env vars: + # inject_block "$config_file" " init" "" + # Use add_alias_if_missing for simple aliases: + # add_alias_if_missing "$config_file" "" "" + # 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 +} + +# ─── Main ───────────────────────────────────────────────────────────── + +main() { + install_ + configure_shell # Remove this line if no shell config is needed + + echo + log_success " installation and configuration complete." +} + +main "$@" +``` + +--- + +## Available Library Functions + +These are pre-loaded by `bootstrap.sh` — no need to source them manually in installers. + +### From `lib/common.sh` + +| Function | Description | +|---|---| +| `log_info "msg"` | Blue `[INFO]` message to stdout | +| `log_success "msg"` | Green `[SUCCESS]` message to stdout | +| `log_warn "msg"` | Yellow `[WARNING]` message to stderr | +| `log_error "msg"` | Red `[ERROR]` message to stderr | +| `confirm "prompt"` | Interactive yes/no prompt, returns 0 for yes | +| `has_command ` | Check if a command exists (returns 0/1) | +| `make_temp_dir` | Create and echo a temp directory path | + +### From `lib/platform.sh` + +| Function | Description | +|---|---| +| `detect_distro` | Echoes `arch`, `debian`, `fedora`, or `unknown` | +| `detect_arch` | Echoes `x86_64` or `arm64` | +| `pkg_install ...` | Install packages via the system package manager. Supports distro-specific mapping: `"arch:pkg_a\|debian:pkg_d\|fedora:pkg_f"` | + +### From `lib/shell_config.sh` + +| Function | Description | +|---|---| +| `get_shell_configs` | Space-separated list of existing RC files (`~/.bashrc`, `~/.zshrc`) | +| `inject_block ` | Idempotently inject a named block into a config file (removes old block first) | +| `remove_block ` | Remove a named block from a config file | +| `add_alias_if_missing ` | Add an alias line if not already present | +| `add_env_if_missing ` | Add an `export VAR="value"` line if not already present | +| `create_fd_symlink` | Symlink `fdfind` → `fd` on Debian/Ubuntu | + +--- + +## Common Patterns + +### Temp directory with cleanup + +```bash +TMP_DIR="$(make_temp_dir)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT +``` + +### Distro-specific installation (e.g., GitHub .deb for Debian, pacman for Arch) + +```bash +local distro +distro=$(detect_distro) + +case "$distro" in + arch) + pkg_install + ;; + debian) + # Download .deb from GitHub releases + ;; + fedora) + pkg_install + ;; + *) + log_error "Unsupported distribution." + exit 1 + ;; +esac +``` + +### Fetching latest GitHub release tag + +```bash +local latest_tag="" +if has_command curl; then + latest_tag=$(curl -sL https://api.github.com/repos///releases/latest \ + | grep '"tag_name":' | head -n1 \ + | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) +elif has_command wget; then + latest_tag=$(wget -qO- https://api.github.com/repos///releases/latest \ + | grep '"tag_name":' | head -n1 \ + | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) +fi + +if [ -z "$latest_tag" ]; then + latest_tag="v1.0.0" # fallback + log_warn "Failed to fetch latest version. Falling back to: $latest_tag" +fi +``` + +### Shell block injection (idempotent) + +```bash +# Block name should be unique and descriptive +inject_block "$config_file" " init" 'eval "$(tool init bash)"' +``` + +--- + +## Rules & Conventions + +1. **File naming**: Always `install_.sh` in the `installers/` directory. +2. **Alphabetical order**: Keep `INSTALLERS` entries and `INSTALLER_KEYS` in alphabetical order in `routes.sh`. +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 sourcing `bootstrap.sh`. +7. **Metascript boilerplate**: The first 22 lines of every installer are identical — always copy them verbatim. +8. **`main "$@"`**: Always end with this pattern to pass through CLI arguments. diff --git a/installers/install_pnpm.sh b/installers/install_pnpm.sh new file mode 100644 index 0000000..104a78c --- /dev/null +++ b/installers/install_pnpm.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# +# pnpm Installer Script +# +# Installs pnpm (fast, disk-space-efficient package manager for Node.js). +# Supports glibc and musl (Alpine) Linux on x86_64 and arm64. +# +# Linux runtime requirements: +# - glibc 2.27+ and libatomic.so.1 (for glibc builds) +# - Debian/Ubuntu: apt-get install -y libatomic1 +# - Fedora/RHEL: dnf install -y libatomic +# +# Docker usage: +# wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash - +# + +# Run metascript to check if the shell is bash and load libraries +PARENT_DIR="$(dirname "$0")/.." +METASCRIPT_LOCAL="$PARENT_DIR/bootstrap.sh" +METASCRIPT_URL="https://git.adityagupta.dev/sortedcord/bootstrap/raw/branch/master/bootstrap.sh" + +if [ -f "$METASCRIPT_LOCAL" ]; then + . "$METASCRIPT_LOCAL" +else + if command -v wget >/dev/null 2>&1; then + eval "$(wget -qO- "$METASCRIPT_URL")" + elif command -v curl >/dev/null 2>&1; then + eval "$(curl -fsSL "$METASCRIPT_URL")" + else + echo "Error: Neither wget nor curl is installed to fetch bootstrap.sh." >&2 + exit 1 + fi +fi + +set -euo pipefail + +TMP_DIR="$(make_temp_dir)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +# ─── Helper Functions ───────────────────────────────────────────────── + +download() { + if has_command curl; then + curl -fsSL "$1" + else + wget -qO- "$1" + fi +} + +is_glibc_compatible() { + getconf GNU_LIBC_VERSION >/dev/null 2>&1 || ldd --version >/dev/null 2>&1 || return 1 +} + +# Detect libc suffix — empty for glibc, "-musl" for musl-based distros +detect_libc_suffix() { + if ! is_glibc_compatible; then + printf -- '-musl' + fi +} + +# pnpm v11.0.0-rc.3 renamed release assets. Older versions use legacy names. +use_legacy_assets() { + local version="$1" + local major + major="$(echo "$version" | cut -d. -f1)" + if [ "$major" -lt 11 ] 2>/dev/null; then + return 0 + fi + case "$version" in + 11.0.0-rc.1|11.0.0-rc.2) return 0 ;; + *) return 1 ;; + esac +} + +# Legacy asset basename for pre-v11.0.0-rc.3 releases +legacy_asset_basename() { + local arch libc_suffix + arch="$1" + libc_suffix="$2" + if [ -n "$libc_suffix" ]; then + printf 'pnpm-linuxstatic-%s' "$arch" + else + printf 'pnpm-linux-%s' "$arch" + fi +} + +# Release-page asset basename (without extension) +asset_basename() { + local version arch libc_suffix + version="$1" + arch="$2" + libc_suffix="$3" + if use_legacy_assets "$version"; then + legacy_asset_basename "$arch" "$libc_suffix" + else + printf 'pnpm-linux-%s%s' "$arch" "$libc_suffix" + fi +} + +# Map system arch to pnpm's naming (x64 / arm64) +detect_pnpm_arch() { + local arch + arch="$(uname -m | tr '[:upper:]' '[:lower:]')" + + case "${arch}" in + x86_64 | amd64) arch="x64" ;; + arm64 | aarch64) arch="arm64" ;; + *) return 1 ;; + esac + + # Double check 32-bit OS reported as 64-bit + if [ "${arch}" = "x64" ] && [ "$(getconf LONG_BIT)" -eq 32 ]; then + return 1 + elif [ "${arch}" = "arm64" ] && [ "$(getconf LONG_BIT)" -eq 32 ]; then + return 1 + fi + + printf '%s' "${arch}" +} + +# ─── Installation Logic ────────────────────────────────────────────── + +install_pnpm() { + if has_command pnpm; then + if ! confirm "pnpm is already installed ($(pnpm --version)). Reinstall/Upgrade?"; then + log_info "Skipping pnpm installation." + return + fi + else + if ! confirm "Install pnpm?"; then + log_info "Skipping pnpm installation." + return + fi + fi + + local arch libc_suffix version_json version major_version asset_base + + arch="$(detect_pnpm_arch)" || { + log_error "pnpm currently only provides pre-built binaries for x86_64/arm64 architectures." + return 1 + } + libc_suffix="$(detect_libc_suffix)" + + # Fetch the latest version from the npm registry, 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." + return 1 + } + version="$(echo "$version_json" | grep -o '"latest":[[:space:]]*"[0-9.]*"' | grep -o '[0-9.]*')" + else + version="${PNPM_VERSION}" + fi + + # Normalize major version (strip leading "v", extract digits) + major_version="$(printf '%s' "$version" | sed -E 's/^v//; s/^([0-9]+).*/\1/')" + if [ -z "$major_version" ]; then + log_error "Invalid PNPM_VERSION: $version" + return 1 + fi + + log_info "Downloading pnpm v${version} (linux-${arch}${libc_suffix})..." + asset_base="$(asset_basename "$version" "$arch" "$libc_suffix")" + + 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" || { + log_error "Failed to download pnpm tarball." + return 1 + } + tar -xzf "$TMP_DIR/pnpm.tar.gz" -C "$TMP_DIR" || { + log_error "Failed to extract pnpm tarball." + return 1 + } + chmod +x "$TMP_DIR/pnpm" + SHELL="${SHELL:-/bin/bash}" "$TMP_DIR/pnpm" setup --force || { + log_error "pnpm setup failed." + return 1 + } + else + # Older versions: distributed as a single executable binary + download "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset_base}" > "$TMP_DIR/pnpm" || { + log_error "Failed to download pnpm binary." + return 1 + } + chmod +x "$TMP_DIR/pnpm" + SHELL="${SHELL:-/bin/bash}" "$TMP_DIR/pnpm" setup --force || { + log_error "pnpm setup failed." + return 1 + } + fi + + log_success "pnpm v${version} installed successfully!" +} + +# ─── 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" +case ":$PATH:" in + *":$PNPM_HOME:"*) ;; + *) export PATH="$PNPM_HOME:$PATH" ;; +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 +} + +# ─── Main ───────────────────────────────────────────────────────────── + +main() { + install_pnpm + configure_shell + + echo + if has_command pnpm; then + log_success "pnpm installation and configuration complete." + 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 (or source ~/.zshrc) to verify." + fi +} + +main "$@" diff --git a/routes.sh b/routes.sh index 03bbb4e..088ccd6 100755 --- a/routes.sh +++ b/routes.sh @@ -21,11 +21,12 @@ declare -A INSTALLERS=( [bat]="Install Bat (alternative to cat) and configure alias" [node]="Install Node.js (LTS) and NVM" [nvim]="Install Neovim 0.11.7 and configuration" + [pnpm]="Install pnpm package manager" [yazi]="Install Yazi terminal file manager and dependencies" [zoxide]="Install Zoxide directory jumper" ) # Order in which installers should be displayed -INSTALLER_KEYS=(agy bat node nvim yazi zoxide) +INSTALLER_KEYS=(agy bat node nvim pnpm yazi zoxide) SCRIPT_NAMES="${1:-}" if [ -z "$SCRIPT_NAMES" ] || [ "$SCRIPT_NAMES" = "-h" ] || [ "$SCRIPT_NAMES" = "--help" ]; then