diff --git a/README.md b/README.md index 969809c..7345762 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,50 @@ # Banyan Labs -> Infrastructure, tooling, and DevOps scaffolding for small to medium enterprises — built to scale from zero to ~600 engineers. +Banyan Labs is now intended to be a project repo that lives inside a shared +workspace managed by the sibling `base` repo. ---- +## Relationship to Base -## What is Banyan Labs? +The shared developer bootstrap layer no longer lives here. -Banyan Labs is an open, opinionated infrastructure and DevOps framework designed to help engineering teams — from solo founders to mid-size companies — bootstrap, manage, and scale their technical stack with consistency and confidence. +That foundational layer now belongs in `base`, including: -It is not a SaaS product. It is a living repository of patterns, tools, scripts, and services that teams can adopt, fork, and extend. +- shell environment bootstrap +- shared Bash wrapper and command conventions +- common shell libraries +- workspace-level setup and test entrypoints ---- - -## Vision - -The goal is to give small and medium engineering teams the same infrastructure discipline that large companies have, without the overhead of building it all from scratch. - -**Target scale:** Teams up to ~2,000 people, ~600 engineers. -**Not intended for:** Hyperscalers like Google, Apple, or Oracle. - ---- - -## Roadmap - -### Phase 1 — Foundation (Months 1–3) -- Set up project structure, tooling wrappers, and documentation -- Implement Bash bootstrapping layer -- Implement Python setup and CLI layer -- Define YAML manifest-based dependency management -- Build core Bash standard library (logging, error handling) -- Build Python virtual environment management -- Daily progress tracked via GitHub Issues - -### Phase 2 — Collaboration -- Onboard ex-colleagues and contributors -- Expand multi-repo support -- Formalize contribution guidelines - -### Phase 3 — Public Launch -- Announce to the broader DevOps and engineering community -- Gather feedback and iterate - -### Phase 4 — Commercial Viability -- Explore sustainable monetization models -- Build partnerships with cloud providers and tooling vendors - ---- - -## Tech Stack - -| Layer | Technology | -|---|---| -| Bootstrapping | Bash | -| Scripting & Glue | Python | -| CLIs (performance-critical) | Go | -| Services (learning & production) | Go, Java | -| Infrastructure as Code | Terraform | -| Configuration formats | YAML, HCL (HashiCorp), JSON | -| Package/dependency manifest | Custom YAML manifest | -| Version control | Git (GitHub) | -| Issue tracking | GitHub Issues | - ---- - -## Architecture Overview - -### Two-Layer Setup System - -Setup is intentionally split into two layers: - -**Layer 1 — Bash Bootstrap** -- Ensures Homebrew is installed -- Installs Python via Homebrew -- Installs any system-level prerequisites (e.g. Xcode CLI tools) -- Hands off to the Python layer once the environment is ready - -**Layer 2 — Python Setup** -- Reads a YAML manifest that defines all tools, packages, and services to install -- Manages installation order (respecting inter-package dependencies) -- Manages Python virtual environments per project -- Handles cloning of additional repositories required for a company's full stack - -### Wrapper Pattern - -Every CLI — whether Bash or Python — is invoked through a wrapper, not directly. This avoids boilerplate repetition and enforces consistency. - -**Bash Wrapper** -- Sources the Bash standard library (logging, error handling, utilities) -- Sets up common environment variables -- Discovers and invokes the target Bash script - -**Python Wrapper** -- Activates the correct Python virtual environment -- Discovers the target Python CLI by convention -- Passes arguments through cleanly - -### CLI Structure Convention - -Every Python CLI is a directory (package), not a standalone file. Each CLI directory contains: +The intended local workspace shape is: +```text +work/ + base/ + banyanlabs/ + other-project/ ``` -my-cli/ - __init__.py - main.py - README.md - tests/ - submodules/ -``` - -### YAML Manifest - -All dependencies — Python packages, system tools, CLIs, services — are declared in a single YAML manifest. There is no `requirements.txt` or `setup.py`. The Python setup layer reads the manifest and handles installation. - -This keeps configuration unified, readable, and language-agnostic. - ---- - -## Multi-Repo Design - -Banyan Labs is designed to be the **onboarding and bootstrapping layer** for an entire company's technical ecosystem. It is not a monorepo. - -- Banyan Labs itself is the entry point -- The YAML manifest can reference and clone additional repositories -- Each product team, service, or tooling area lives in its own repo -- Banyan Labs orchestrates the setup of the full environment in one step - ---- - -## Repository Structure - -``` -banyan-labs/ - README.md - docs/ - 01-vision.md - 02-architecture.md - 03-setup-strategy.md - 04-multi-repo-design.md - 05-tech-stack.md - 06-cli-conventions.md - 07-yaml-manifest-spec.md - scripts/ - wrapper.sh # Bash wrapper - wrapper.py # Python wrapper - bootstrap.sh # Layer 1 setup - lib/ - stdlib.sh # Bash standard library - setup/ - setup.py # Layer 2 Python setup entrypoint - manifest.yaml # Dependency manifest - services/ - url-shortener/ # Example Go service (learning project) - infra/ - terraform/ - .github/ - ISSUE_TEMPLATE/ -``` - ---- - -## Git Workflow - -- All work is done on feature branches, never directly on `main` -- Branch naming convention: `---` - - Example: `pd-0411-bash-stdlib-12` -- Every branch is backed by a GitHub Issue -- PRs are opened against `main` and merged after self-review -- Branches are deleted after merge - ---- - -## Dependency Philosophy -- No `requirements.txt` -- No `setup.py` -- All Python dependencies are declared in `manifest.yaml` -- The Python setup layer installs them in the declared order -- This project is not distributed as a pip-installable package — it is used directly from the repo +In that model: ---- +- `base` owns shared developer tooling and workspace orchestration +- `banyanlabs` owns Banyan Labs-specific code, manifests, infrastructure, and + project behavior -## Documentation +## What This Repo Will Focus On -All architecture decisions, design rationale, and implementation notes are documented in `/docs`. Markdown files are numbered to indicate reading order. The main `README.md` links to all docs. +As the split continues, Banyan Labs should increasingly focus on project-level +concerns such as: -When sharing project context with other tools (e.g. OpenAI Codex), paste the relevant docs to provide architectural grounding. - ---- +- project source code +- infrastructure definitions +- service code +- manifests and configuration specific to Banyan Labs +- project-specific documentation +- project-specific tests and automation ## Status -**Current phase:** Phase 1 — active development started April 2025. - -Progress is tracked daily via [GitHub Issues](../../issues). - ---- - -## Contributing - -Collaboration opens in Phase 2. If you are one of the initial collaborators, reach out directly. Public contributions will be welcomed in Phase 3 after the initial structure is stable. - ---- +This repo is in transition. -*Banyan Labs — Infrastructure that grows with you.* +The first migration pass moved the shared CLI/bootstrap artifacts out of +`banyanlabs` and into the sibling `base` repo. Additional project-specific +structure will be rebuilt here on top of that new workspace model. diff --git a/cli/bash/bin/README.md b/cli/bash/bin/README.md deleted file mode 100644 index 9a91ade..0000000 --- a/cli/bash/bin/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# `cli/bash/bin` - -This directory holds the user-facing Bash entrypoints. - -## Layout - -- `bash-wrapper` - The shared dispatcher used to launch Bash commands. -- `.sh` symlinks - Each command symlink points to `bash-wrapper`. The wrapper uses the invoked filename to decide which command to run. -- `tests/` - Wrapper-specific BATS coverage for `bash-wrapper`. - -## How `bash-wrapper` Works - -The wrapper supports two invocation styles: - -```bash -bash-wrapper .sh [args...] -.sh [args...] -``` - -Behavior: - -- When invoked as `bash-wrapper`, the first argument is treated as the command name. -- Bash entrypoint symlinks are expected to end in `.sh`. -- When invoked through a symlink, the wrapper strips the `.sh` suffix and uses the remaining name as the command name. -- Commands are resolved under `../commands//.sh`. - -## What the Wrapper Provides - -Before sourcing the command script, `bash-wrapper`: - -- sources `../../env/banyanenv.sh` to initialize the shared CLI environment -- resolves the repository, CLI, and Bash root directories -- exports wrapper metadata: - - `BANYAN_REPO_ROOT` - - `BANYAN_CLI_ROOT` - - `BANYAN_BASH_ROOT` - - `BANYAN_BASH_BIN_DIR` - - `BANYAN_CLI_ENV_SCRIPT` - - `BANYAN_BASH_COMMAND_NAME` - - `BANYAN_BASH_COMMAND_DIR` - - `BANYAN_BASH_COMMAND_SCRIPT` -- preloads `../lib/std/lib_std.sh` - -That means command scripts inherit both the shared environment and the stdlib helpers without having to source them directly. - -The wrapper also sets `BANYAN_BASH_BOOTSTRAP_SOURCE` before loading the stdlib so stdlib path detection still treats the command script as the real caller. - -`banyanenv.sh` is also meant to be sourced from a user's shell startup file: - -```bash -source /path/to/banyanlabs/cli/env/banyanenv.sh -``` - -That keeps interactive shells and wrapper-launched commands on the same environment contract. - -## Examples - -Direct dispatch: - -```bash -cli/bash/bin/bash-wrapper my-command.sh --flag value -``` - -Symlink dispatch: - -```bash -ln -s bash-wrapper cli/bash/bin/my-command.sh -cli/bash/bin/my-command.sh --flag value -``` - -## Tests - -Run the wrapper test suite with: - -```bash -cd cli/bash -bats bin/tests/bash-wrapper.bats -``` diff --git a/cli/bash/bin/bash-wrapper b/cli/bash/bin/bash-wrapper deleted file mode 100755 index 0ec7603..0000000 --- a/cli/bash/bin/bash-wrapper +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash - -die() { - printf 'ERROR: %s\n' "$*" >&2 - exit 1 -} - -resolve_script_path() { - local source_path="${1:-}" link_dir target - - [[ -n "$source_path" ]] || return 1 - - if [[ "$source_path" != */* ]]; then - source_path="$(command -v -- "$source_path" 2>/dev/null || true)" - [[ -n "$source_path" ]] || return 1 - fi - - while [[ -L "$source_path" ]]; do - link_dir="$(cd "$(dirname "$source_path")" && pwd -P)" - target="$(readlink "$source_path")" - if [[ "$target" == /* ]]; then - source_path="$target" - else - source_path="$link_dir/$target" - fi - done - - link_dir="$(cd "$(dirname "$source_path")" && pwd -P)" - printf '%s/%s\n' "$link_dir" "$(basename "$source_path")" -} - -normalize_command_name() { - local command_name="${1:-}" - - if [[ "$command_name" == *.sh && "$command_name" != ".sh" ]]; then - command_name="${command_name%.sh}" - fi - - printf '%s\n' "$command_name" -} - -list_commands() { - local commands_dir="$1" - local command_dir command_name found=0 - - [[ -d "$commands_dir" ]] || return 0 - - while IFS= read -r command_dir; do - [[ -d "$command_dir" ]] || continue - command_name="$(basename "$command_dir")" - if [[ -f "$command_dir/${command_name}.sh" ]]; then - printf ' %s.sh\n' "$command_name" - found=1 - fi - done < <(find "$commands_dir" -mindepth 1 -maxdepth 1 -type d | sort) - - ((found)) || printf ' (none yet)\n' -} - -print_usage() { - local wrapper_name="$1" - local commands_dir="$2" - - cat < [args...] - [args...] - -Behavior: - - When invoked as 'bash-wrapper', the first argument is treated as the command name. - - Bash entrypoint symlinks are expected to end in '.sh'; the wrapper maps '.sh' to command ''. - - When invoked through a symlink, the symlink name is treated as the command name. - - Commands are resolved under cli/bash/commands//.sh. - -Available commands: -EOF - list_commands "$commands_dir" -} - -main() { - local invoked_as wrapper_path bin_dir bash_root cli_root repo_root commands_dir stdlib_path env_script - local command_name command_dir command_script - - invoked_as="$(basename "$0")" - wrapper_path="$(resolve_script_path "$0")" || die "Unable to resolve wrapper path for '$0'." - bin_dir="$(cd "$(dirname "$wrapper_path")" && pwd -P)" - bash_root="$(cd "$bin_dir/.." && pwd -P)" - cli_root="$(cd "$bash_root/.." && pwd -P)" - repo_root="$(cd "$cli_root/.." && pwd -P)" - commands_dir="$bash_root/commands" - - if [[ "$invoked_as" == "bash-wrapper" ]]; then - case "${1:-}" in - "" ) - print_usage "$invoked_as" "$commands_dir" - exit 1 - ;; - -h|--help|help ) - print_usage "$invoked_as" "$commands_dir" - exit 0 - ;; - --list|list ) - list_commands "$commands_dir" - exit 0 - ;; - esac - - command_name="$1" - shift - else - command_name="$invoked_as" - fi - - command_name="$(normalize_command_name "$command_name")" - - case "$command_name" in - ""|.|..|*/* ) - die "Invalid command name '$command_name'." - ;; - esac - - env_script="$cli_root/env/banyanenv.sh" - [[ -f "$env_script" ]] || die "Required environment bootstrap '$env_script' was not found." - source "$env_script" || die "Failed to initialize Banyan CLI environment from '$env_script'." - - bash_root="${BANYAN_BASH_ROOT:-$bash_root}" - cli_root="${BANYAN_CLI_ROOT:-$cli_root}" - repo_root="${BANYAN_REPO_ROOT:-$repo_root}" - bin_dir="${BANYAN_BASH_BIN_DIR:-$bin_dir}" - commands_dir="${BANYAN_BASH_COMMANDS_DIR:-$commands_dir}" - env_script="${BANYAN_CLI_ENV_SCRIPT:-$env_script}" - - command_dir="$commands_dir/$command_name" - if [[ -f "$command_dir/${command_name}.sh" ]]; then - command_script="$command_dir/${command_name}.sh" - else - die "Command '$command_name' was not found under '$command_dir'." - fi - - export BANYAN_REPO_ROOT="$repo_root" - export BANYAN_CLI_ROOT="$cli_root" - export BANYAN_BASH_ROOT="$bash_root" - export BANYAN_BASH_BIN_DIR="$bin_dir" - export BANYAN_CLI_ENV_SCRIPT="$env_script" - export BANYAN_BASH_COMMAND_NAME="$command_name" - export BANYAN_BASH_COMMAND_DIR="$command_dir" - export BANYAN_BASH_COMMAND_SCRIPT="$command_script" - - stdlib_path="$bash_root/lib/std/lib_std.sh" - [[ -f "$stdlib_path" ]] || die "Required stdlib '$stdlib_path' was not found." - - export BANYAN_BASH_BOOTSTRAP_SOURCE="$command_script" - # Source the stdlib in the wrapper shell so command scripts inherit the shared helpers. - source "$stdlib_path" - unset BANYAN_BASH_BOOTSTRAP_SOURCE - - # Source the command in the same shell so it can use stdlib helpers without per-command boilerplate. - source "$command_script" -} - -main "$@" diff --git a/cli/bash/bin/setup.sh b/cli/bash/bin/setup.sh deleted file mode 120000 index e118386..0000000 --- a/cli/bash/bin/setup.sh +++ /dev/null @@ -1 +0,0 @@ -bash-wrapper \ No newline at end of file diff --git a/cli/bash/bin/test_cmd.sh b/cli/bash/bin/test_cmd.sh deleted file mode 120000 index e118386..0000000 --- a/cli/bash/bin/test_cmd.sh +++ /dev/null @@ -1 +0,0 @@ -bash-wrapper \ No newline at end of file diff --git a/cli/bash/bin/tests/bash-wrapper.bats b/cli/bash/bin/tests/bash-wrapper.bats deleted file mode 100644 index 9fee5b5..0000000 --- a/cli/bash/bin/tests/bash-wrapper.bats +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env bats - -load ../../tests/test_helper.bash - -create_bare_wrapper_layout() { - local layout_root="$1" - local cli_root - - cli_root="$(dirname "$layout_root")" - - mkdir -p "$layout_root/bin" "$layout_root/commands" "$layout_root/lib/std" "$cli_root/env" "$cli_root/python" - cp "$BANYAN_REPO_ROOT/cli/env/banyanenv.sh" "$cli_root/env/banyanenv.sh" - cp "$BANYAN_BASH_DIR/bin/bash-wrapper" "$layout_root/bin/bash-wrapper" - cp "$BANYAN_BASH_DIR/lib/std/lib_std.sh" "$layout_root/lib/std/lib_std.sh" - chmod +x "$layout_root/bin/bash-wrapper" -} - -create_wrapper_layout() { - local layout_root="$1" - local command_name="$2" - local command_script_name="${3:-$command_name.sh}" - - create_bare_wrapper_layout "$layout_root" - mkdir -p "$layout_root/commands/$command_name" - - cat > "$layout_root/commands/$command_name/$command_script_name" <<'EOF' -#!/usr/bin/env bash -printf 'script_dir=%s\n' "${__SCRIPT_DIR__:-}" -printf 'orig_args=%s\n' "${__SCRIPT_ARGS__[*]:-}" -printf 'command=%s\n' "${BANYAN_BASH_COMMAND_NAME:-}" -printf 'repo=%s\n' "${BANYAN_REPO_ROOT:-}" -printf 'bash_root=%s\n' "${BANYAN_BASH_ROOT:-}" -printf 'bin_dir=%s\n' "${BANYAN_BASH_BIN_DIR:-}" -printf 'env_script=%s\n' "${BANYAN_CLI_ENV_SCRIPT:-}" -printf 'script=%s\n' "${BANYAN_BASH_COMMAND_SCRIPT:-}" -case ":$PATH:" in - *":${BANYAN_BASH_BIN_DIR:-__missing__}:"*) printf 'path_has_bin=yes\n' ;; - *) printf 'path_has_bin=no\n' ;; -esac -printf 'argv=%s\n' "$*" -EOF - chmod +x "$layout_root/commands/$command_name/$command_script_name" -} - -@test "bash-wrapper dispatches directly to commands//.sh" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - local expected_repo_root expected_bash_root expected_bin_dir expected_env_script expected_script_path - local expected_command_dir - - create_wrapper_layout "$layout" demo - expected_repo_root="$(cd "$repo_root" && pwd -P)" - expected_bash_root="$(cd "$layout" && pwd -P)" - expected_bin_dir="$(cd "$layout/bin" && pwd -P)" - expected_env_script="$(cd "$repo_root/cli/env" && pwd -P)/banyanenv.sh" - expected_command_dir="$(cd "$layout/commands/demo" && pwd -P)" - expected_script_path="$(cd "$layout/commands/demo" && pwd -P)/demo.sh" - - run "$layout/bin/bash-wrapper" demo.sh --debug-wrapper alpha beta - - [ "$status" -eq 0 ] - [[ "$output" == *"script_dir=$expected_command_dir"* ]] - [[ "$output" == *"orig_args=--debug-wrapper alpha beta"* ]] - [[ "$output" == *"command=demo"* ]] - [[ "$output" == *"repo=$expected_repo_root"* ]] - [[ "$output" == *"bash_root=$expected_bash_root"* ]] - [[ "$output" == *"bin_dir=$expected_bin_dir"* ]] - [[ "$output" == *"env_script=$expected_env_script"* ]] - [[ "$output" == *"script=$expected_script_path"* ]] - [[ "$output" == *"path_has_bin=yes"* ]] - [[ "$output" == *"argv=alpha beta"* ]] -} - -@test "symlink name with .sh suffix selects the command" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - local expected_script_path - local expected_command_dir - - create_wrapper_layout "$layout" greet - ln -s bash-wrapper "$layout/bin/greet.sh" - expected_command_dir="$(cd "$layout/commands/greet" && pwd -P)" - expected_script_path="$(cd "$layout/commands/greet" && pwd -P)/greet.sh" - - run "$layout/bin/greet.sh" hello world - - [ "$status" -eq 0 ] - [[ "$output" == *"script_dir=$expected_command_dir"* ]] - [[ "$output" == *"command=greet"* ]] - [[ "$output" == *"script=$expected_script_path"* ]] - [[ "$output" == *"argv=hello world"* ]] -} - -@test "wrapper prints usage when no command is provided" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - - run "$layout/bin/bash-wrapper" - - [ "$status" -eq 1 ] - [[ "$output" == *"Usage:"* ]] -} - -@test "wrapper prints usage for help flags" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - - run "$layout/bin/bash-wrapper" --help - - [ "$status" -eq 0 ] - [[ "$output" == *"Usage:"* ]] - [[ "$output" == *"Available commands:"* ]] -} - -@test "wrapper lists commands with command scripts and skips empty directories" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_wrapper_layout "$layout" alpha - mkdir -p "$layout/commands/empty-dir" - mkdir -p "$layout/commands/readme-only" - printf '# no script here\n' > "$layout/commands/readme-only/README.md" - mkdir -p "$layout/commands/legacy" - cat > "$layout/commands/legacy/legacy.sh" <<'EOF' -#!/usr/bin/env bash -echo "legacy" -EOF - chmod +x "$layout/commands/legacy/legacy.sh" - mkdir -p "$layout/commands/main-only" - cat > "$layout/commands/main-only/main.sh" <<'EOF' -#!/usr/bin/env bash -echo "main-only" -EOF - chmod +x "$layout/commands/main-only/main.sh" - - run "$layout/bin/bash-wrapper" --list - - [ "$status" -eq 0 ] - [[ "$output" == *" alpha.sh"* ]] - [[ "$output" == *" legacy.sh"* ]] - [[ "$output" != *"empty-dir"* ]] - [[ "$output" != *"main-only"* ]] - [[ "$output" != *"readme-only"* ]] -} - -@test "wrapper lists none when no commands exist yet" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - - run "$layout/bin/bash-wrapper" --list - - [ "$status" -eq 0 ] - [[ "$output" == *" (none yet)"* ]] -} - -@test "wrapper rejects invalid command names in direct mode" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - - run "$layout/bin/bash-wrapper" ../bad - - [ "$status" -eq 1 ] - [[ "$output" == *"Invalid command name '../bad'."* ]] -} - -@test "wrapper errors when the command directory is missing" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - - run "$layout/bin/bash-wrapper" missing.sh - - [ "$status" -eq 1 ] - [[ "$output" == *"Command 'missing' was not found"* ]] -} - -@test "wrapper rejects main.sh-only command directories" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - mkdir -p "$layout/commands/legacy" - cat > "$layout/commands/legacy/main.sh" <<'EOF' -#!/usr/bin/env bash -echo "legacy" -EOF - chmod +x "$layout/commands/legacy/main.sh" - - run "$layout/bin/bash-wrapper" legacy.sh - - [ "$status" -eq 1 ] - [[ "$output" == *"Command 'legacy' was not found"* ]] -} - -@test "wrapper errors when the stdlib is missing" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_wrapper_layout "$layout" demo - rm -f "$layout/lib/std/lib_std.sh" - - run "$layout/bin/bash-wrapper" demo - - [ "$status" -eq 1 ] - [[ "$output" == *"Required stdlib"* ]] -} - -@test "wrapper errors when banyanenv is missing" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_wrapper_layout "$layout" demo - rm -f "$repo_root/cli/env/banyanenv.sh" - - run "$layout/bin/bash-wrapper" demo - - [ "$status" -eq 1 ] - [[ "$output" == *"Required environment bootstrap"* ]] -} - -@test "wrapper preloads stdlib so commands can call stdlib helpers without sourcing it" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - mkdir -p "$layout/commands/stdlib-demo" - cat > "$layout/commands/stdlib-demo/stdlib-demo.sh" <<'EOF' -#!/usr/bin/env bash -set_log_level DEBUG -run echo "wrapped output" -safe_touch "$BATS_TEST_TMPDIR/stdout.txt" -printf 'touched=%s\n' "$BATS_TEST_TMPDIR/stdout.txt" -EOF - chmod +x "$layout/commands/stdlib-demo/stdlib-demo.sh" - - run "$layout/bin/bash-wrapper" stdlib-demo - - [ "$status" -eq 0 ] - [[ "$output" == *"wrapped output"* ]] - [[ "$output" == *"touched=$BATS_TEST_TMPDIR/stdout.txt"* ]] - [ -f "$BATS_TEST_TMPDIR/stdout.txt" ] -} - -@test "wrapper strips wrapper flags before the command sees argv" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - mkdir -p "$layout/commands/flags" - cat > "$layout/commands/flags/flags.sh" <<'EOF' -#!/usr/bin/env bash -printf 'orig=%s\n' "${__SCRIPT_ARGS__[*]}" -printf 'argv=%s\n' "$*" -printf 'log_debug=%s\n' "${LOG_DEBUG:-}" -printf 'log_utc=%s\n' "${LOG_UTC:-}" -EOF - chmod +x "$layout/commands/flags/flags.sh" - - run "$layout/bin/bash-wrapper" flags --verbose-wrapper --utc-wrapper --color one two - - [ "$status" -eq 0 ] - [[ "$output" == *"orig=--verbose-wrapper --utc-wrapper --color one two"* ]] - [[ "$output" == *"argv=one two"* ]] - [[ "$output" == *"log_debug=1"* ]] - [[ "$output" == *"log_utc=1"* ]] -} - -@test "symlink invocation reports missing command scripts for the symlink name" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local layout="$repo_root/cli/bash" - - create_bare_wrapper_layout "$layout" - ln -s bash-wrapper "$layout/bin/orphan.sh" - mkdir -p "$layout/commands/orphan" - - run "$layout/bin/orphan.sh" - - [ "$status" -eq 1 ] - [[ "$output" == *"Command 'orphan' was not found"* ]] -} diff --git a/cli/bash/commands/setup/README.md b/cli/bash/commands/setup/README.md deleted file mode 100644 index b07e938..0000000 --- a/cli/bash/commands/setup/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# `setup` - -Bootstrap the local Banyan Labs CLI environment on macOS. - -## What It Does - -The command is intentionally small and idempotent. - -## Commands - -- `install` - Installs Homebrew, Xcode Command Line Tools, Python 3.13, BATS, and creates `$HOME/.banyanlabs.d/.venv`. -- `check` - Verifies the required local CLI setup without making changes. Exits non-zero if anything is missing. -- `update-profile` - Reserved for future shell profile updates. This subcommand is not implemented yet. - -## Install Behavior - -The `install` command performs these steps: - -1. install Homebrew if it is not already installed -2. install Xcode Command Line Tools if they are not already installed -3. install Python 3.13 via Homebrew if it is not already installed -4. install BATS via Homebrew if it is not already installed -5. create `$HOME/.banyanlabs.d/.venv` if it does not already exist - -The `.banyanlabs.d` directory is intended to hold additional Banyan Labs CLI state in the future, so the virtual environment now lives under that shared home. - -## Check Behavior - -The `check` command verifies the same base requirements as `install`: - -1. Homebrew is installed -2. Xcode Command Line Tools are installed -3. Python 3.13 is installed via Homebrew -4. BATS is installed via Homebrew -5. `$HOME/.banyanlabs.d/.venv` exists - -It exits with status `0` when everything is present and `1` when any required item is missing. - -## What It Does Not Do Yet - -- update shell profiles such as `~/.bashrc` or `~/.zshrc` -- uninstall previously installed tools -- manage application-specific Python packages inside the virtual environment - -## Usage - -Via the wrapper: - -```bash -cli/bash/bin/bash-wrapper setup.sh install -``` - -Via the symlinked entrypoint: - -```bash -cli/bash/bin/setup.sh install -``` - -Check: - -```bash -cli/bash/bin/setup.sh check -``` - -Help: - -```bash -cli/bash/bin/setup.sh --help -``` - -Dry run: - -```bash -cli/bash/bin/setup.sh install --dry-run -``` - -Verbose/debug logging: - -```bash -cli/bash/bin/setup.sh -v install -``` - -## Configuration - -The command supports a few environment-variable overrides, mainly for automation and tests: - -- `BANYAN_SETUP_VENV_DIR` -- `BANYAN_SETUP_PYTHON_FORMULA` -- `BANYAN_SETUP_BATS_FORMULA` -- `BANYAN_SETUP_PYTHON_BIN` -- `BANYAN_SETUP_BREW_BIN` -- `BANYAN_SETUP_HOMEBREW_INSTALLER_SCRIPT` -- `BANYAN_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR` - -## Tests - -Run the command test suite with: - -```bash -bats cli/bash/commands/setup/tests/setup.bats -``` diff --git a/cli/bash/commands/setup/setup.sh b/cli/bash/commands/setup/setup.sh deleted file mode 100644 index ddde826..0000000 --- a/cli/bash/commands/setup/setup.sh +++ /dev/null @@ -1,431 +0,0 @@ -#!/usr/bin/env bash - -setup_usage() { - cat <<'EOF' -Usage: - setup [options] - -Commands: - install - Install Homebrew, Xcode Command Line Tools, Python, BATS, and ~/.banyanlabs.d/.venv. - check - Verify the required local CLI setup without making changes. - update-profile - Reserved for future shell profile updates. - -Options: - --dry-run Log what would happen without making changes. - -v Enable DEBUG logging for this command. - -h, --help Show this help text. - -Purpose: - Prepare and verify the local Banyan Labs CLI environment on macOS. - -Install does: - 1. Install Homebrew if needed. - 2. Install Xcode Command Line Tools if needed. - 3. Install Python 3.13 via Homebrew if needed. - 4. Install BATS via Homebrew if needed. - 5. Create ~/.banyanlabs.d/.venv if it does not already exist. - -Check does: - 1. Verify Homebrew is installed. - 2. Verify Xcode Command Line Tools are installed. - 3. Verify Python 3.13 is installed via Homebrew. - 4. Verify BATS is installed via Homebrew. - 5. Verify ~/.banyanlabs.d/.venv exists. - -Notes: - - This command is intentionally idempotent. - - It does not support uninstall yet. -EOF -} - -setup_is_dry_run() { - [[ "${DRY_RUN-}" == true || "${dry_run-}" == true ]] -} - -setup_virtualenv_exists() { - local venv_dir - - venv_dir="$(setup_venv_dir)" - [[ -f "$venv_dir/bin/activate" || -f "$venv_dir/pyvenv.cfg" ]] -} - -setup_venv_dir() { - printf '%s\n' "${BANYAN_SETUP_VENV_DIR:-$HOME/.banyanlabs.d/.venv}" -} - -setup_python_formula() { - printf '%s\n' "${BANYAN_SETUP_PYTHON_FORMULA:-python@3.13}" -} - -setup_bats_formula() { - printf '%s\n' "${BANYAN_SETUP_BATS_FORMULA:-bats-core}" -} - -setup_xcode_tools_dir() { - printf '%s\n' "${BANYAN_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR:-/Library/Developer/CommandLineTools}" -} - -setup_xcode_wait_timeout_seconds() { - printf '%s\n' "${BANYAN_SETUP_XCODE_WAIT_TIMEOUT_SECONDS:-1800}" -} - -setup_xcode_wait_interval_seconds() { - printf '%s\n' "${BANYAN_SETUP_XCODE_WAIT_INTERVAL_SECONDS:-5}" -} - -setup_allow_noninteractive_xcode_install() { - [[ "${BANYAN_SETUP_ALLOW_NONINTERACTIVE_XCODE_INSTALL:-false}" == true ]] -} - -setup_find_brew_bin() { - local candidate - - if [[ -n "${BANYAN_SETUP_BREW_BIN+x}" ]]; then - if [[ -x "${BANYAN_SETUP_BREW_BIN}" ]]; then - printf '%s\n' "${BANYAN_SETUP_BREW_BIN}" - return 0 - fi - return 1 - fi - - if command -v brew >/dev/null 2>&1; then - command -v brew - return 0 - fi - - for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do - [[ -x "$candidate" ]] || continue - printf '%s\n' "$candidate" - return 0 - done - - return 1 -} - -setup_refresh_brew_path() { - local brew_bin - - brew_bin="$(setup_find_brew_bin)" || return 1 - add_to_path -p "$(dirname "$brew_bin")" - return 0 -} - -setup_install_homebrew() { - local installer_url="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" - - if setup_find_brew_bin >/dev/null 2>&1; then - setup_refresh_brew_path || fatal_error "Homebrew is installed, but its bin directory could not be added to PATH." - log_info "Homebrew is already installed." - return 0 - fi - - if setup_is_dry_run; then - log_info "[DRY-RUN] Would install Homebrew using the official installer." - return 0 - fi - - log_info "Installing Homebrew." - - if [[ -n "${BANYAN_SETUP_HOMEBREW_INSTALLER_SCRIPT:-}" ]]; then - run "$BANYAN_SETUP_HOMEBREW_INSTALLER_SCRIPT" - else - command -v curl >/dev/null 2>&1 || fatal_error "curl is required to install Homebrew." - /bin/bash -c "$(curl -fsSL "$installer_url")" - exit_if_error $? "Homebrew installation failed." - fi - - setup_refresh_brew_path || fatal_error "Homebrew installation finished, but 'brew' was not found on PATH." -} - -setup_require_macos() { - [[ "$OSTYPE" == darwin* ]] || fatal_error "The setup command currently supports macOS only (OSTYPE='$OSTYPE')." -} - -setup_xcode_tools_installed() { - local tools_dir - - tools_dir="$(setup_xcode_tools_dir)" - xcode-select -p >/dev/null 2>&1 && - [[ -d "$tools_dir" ]] && - xcrun -f clang >/dev/null 2>&1 -} - -setup_install_xcode_tools() { - local timeout interval start_time current_time - - if setup_xcode_tools_installed; then - log_info "Xcode Command Line Tools are already installed." - return 0 - fi - - if ! is_interactive && ! setup_allow_noninteractive_xcode_install && ! setup_is_dry_run; then - fatal_error "Xcode Command Line Tools installation requires an interactive terminal." - fi - - log_info "Installing Xcode Command Line Tools." - run --no-exit xcode-select --install - - if setup_is_dry_run; then - log_info "[DRY-RUN] Would wait for Xcode Command Line Tools installation to complete." - return 0 - fi - - timeout="$(setup_xcode_wait_timeout_seconds)" - interval="$(setup_xcode_wait_interval_seconds)" - start_time="$(date +%s)" - - until setup_xcode_tools_installed; do - current_time="$(date +%s)" - if ((current_time - start_time >= timeout)); then - fatal_error "Timed out waiting for Xcode Command Line Tools installation to complete." - fi - sleep "$interval" - done - - log_info "Xcode Command Line Tools installation detected." -} - -setup_python_installed() { - local formula - - formula="$(setup_python_formula)" - command -v brew >/dev/null 2>&1 && brew list "$formula" >/dev/null 2>&1 -} - -setup_install_python() { - local formula - - formula="$(setup_python_formula)" - - if setup_python_installed; then - log_info "Python formula '$formula' is already installed via Homebrew." - return 0 - fi - - if setup_is_dry_run; then - log_info "[DRY-RUN] Would install Python formula '$formula' via Homebrew." - return 0 - fi - - command -v brew >/dev/null 2>&1 || fatal_error "Homebrew is required to install Python formula '$formula'." - - log_info "Installing Python formula '$formula' via Homebrew." - run brew install "$formula" -} - -setup_bats_installed() { - local formula - - formula="$(setup_bats_formula)" - command -v brew >/dev/null 2>&1 && brew list "$formula" >/dev/null 2>&1 -} - -setup_install_bats() { - local formula - - formula="$(setup_bats_formula)" - - if setup_bats_installed; then - log_info "BATS formula '$formula' is already installed via Homebrew." - return 0 - fi - - if setup_is_dry_run; then - log_info "[DRY-RUN] Would install BATS formula '$formula' via Homebrew." - return 0 - fi - - command -v brew >/dev/null 2>&1 || fatal_error "Homebrew is required to install BATS formula '$formula'." - - log_info "Installing BATS formula '$formula' via Homebrew." - run brew install "$formula" -} - -setup_find_python_bin() { - local formula prefix candidate candidates=() - - if [[ -n "${BANYAN_SETUP_PYTHON_BIN:-}" && -x "${BANYAN_SETUP_PYTHON_BIN}" ]]; then - printf '%s\n' "${BANYAN_SETUP_PYTHON_BIN}" - return 0 - fi - - formula="$(setup_python_formula)" - if command -v brew >/dev/null 2>&1; then - prefix="$(brew --prefix "$formula" 2>/dev/null || true)" - if [[ -n "$prefix" ]]; then - candidates+=("$prefix/bin/python3") - candidates+=("$prefix/libexec/bin/python3") - if [[ "$formula" == python@* ]]; then - candidates+=("$prefix/bin/python${formula#python@}") - candidates+=("$prefix/libexec/bin/python${formula#python@}") - fi - for candidate in "${candidates[@]}"; do - if [[ -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - fi - fi - - if command -v python3 >/dev/null 2>&1; then - command -v python3 - return 0 - fi - - return 1 -} - -setup_create_virtualenv() { - local venv_dir python_bin - - venv_dir="$(setup_venv_dir)" - - if setup_virtualenv_exists; then - log_info "Virtual environment already exists at '$venv_dir'." - return 0 - fi - - if setup_is_dry_run; then - log_info "[DRY-RUN] Would create Python virtual environment at '$venv_dir'." - return 0 - fi - - python_bin="$(setup_find_python_bin)" || fatal_error "Unable to locate a python3 executable after installation." - - safe_mkdir -p "$(dirname "$venv_dir")" - log_info "Creating Python virtual environment at '$venv_dir'." - run "$python_bin" -m venv "$venv_dir" -} - -setup_run_check() { - local brew_bin="" venv_dir missing=0 - - setup_require_macos - venv_dir="$(setup_venv_dir)" - - if brew_bin="$(setup_find_brew_bin)"; then - setup_refresh_brew_path || fatal_error "Homebrew is installed, but its bin directory could not be added to PATH." - log_info "Homebrew is installed." - log_debug "Resolved Homebrew binary: $brew_bin" - else - log_warn "Homebrew is not installed." - missing=1 - fi - - if setup_xcode_tools_installed; then - log_info "Xcode Command Line Tools are installed." - else - log_warn "Xcode Command Line Tools are not installed." - missing=1 - fi - - if setup_python_installed; then - log_info "Python formula '$(setup_python_formula)' is installed via Homebrew." - else - log_warn "Python formula '$(setup_python_formula)' is not installed via Homebrew." - missing=1 - fi - - if setup_bats_installed; then - log_info "BATS formula '$(setup_bats_formula)' is installed via Homebrew." - else - log_warn "BATS formula '$(setup_bats_formula)' is not installed via Homebrew." - missing=1 - fi - - if setup_virtualenv_exists; then - log_info "Virtual environment exists at '$venv_dir'." - else - log_warn "Virtual environment is missing at '$venv_dir'." - missing=1 - fi - - if ((missing == 0)); then - log_info "Banyan Labs CLI environment check passed." - return 0 - fi - - log_warn "Banyan Labs CLI environment check found missing requirements." - return 1 -} - -setup_run_install() { - setup_require_macos - setup_install_homebrew - setup_install_xcode_tools - setup_install_python - setup_install_bats - setup_create_virtualenv - - if setup_is_dry_run; then - log_info "[DRY-RUN] Banyan Labs CLI setup check is complete." - else - log_info "Banyan Labs CLI setup is complete." - fi -} - -setup_run_update_profile() { - print_warn "The 'update-profile' subcommand is not implemented yet." - return 1 -} - -setup_main() { - local command="" - - while (($#)); do - case "$1" in - -h|--help|help) - setup_usage - return 0 - ;; - --dry-run) - dry_run=true - export DRY_RUN=true - ;; - -v) - set_log_level DEBUG - export LOG_DEBUG=1 - ;; - install|check|update-profile) - if [[ -n "$command" ]]; then - print_error "Only one setup command may be provided." - setup_usage >&2 - return 1 - fi - command="$1" - ;; - *) - print_error "Unknown option or command '$1'." - setup_usage >&2 - return 1 - ;; - esac - shift - done - - if [[ -z "$command" ]]; then - print_error "A setup command is required." - setup_usage >&2 - return 1 - fi - - log_debug "Running setup command '$command' (dry_run=${dry_run:-false})." - - case "$command" in - install) - setup_run_install - ;; - check) - setup_run_check - ;; - update-profile) - setup_run_update_profile - ;; - esac -} - -setup_main "$@" diff --git a/cli/bash/commands/setup/tests/setup.bats b/cli/bash/commands/setup/tests/setup.bats deleted file mode 100644 index 072b5c8..0000000 --- a/cli/bash/commands/setup/tests/setup.bats +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env bats - -load ../../../tests/test_helper.bash - -setup() { - setup_test_tmpdir - TEST_HOME="$TEST_TMPDIR/home" - TEST_MOCKBIN="$TEST_TMPDIR/mockbin" - TEST_STATE_DIR="$TEST_TMPDIR/state" - TEST_BASH_BIN_DIR="$(dirname "$(command -v bash)")" - unset OSTYPE_OVERRIDE - - mkdir -p "$TEST_HOME" "$TEST_MOCKBIN" "$TEST_STATE_DIR" -} - -create_xcode_stubs() { - cat > "$TEST_MOCKBIN/xcode-select" <<'EOF' -#!/usr/bin/env bash -tools_dir="${BANYAN_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR:?}" -state_dir="${BANYAN_SETUP_TEST_STATE_DIR:?}" -installed_file="$state_dir/xcode-installed" - -case "${1:-}" in - -p) - if [[ -f "$installed_file" ]]; then - printf '%s\n' "$tools_dir" - exit 0 - fi - exit 1 - ;; - --install) - touch "$installed_file" - mkdir -p "$tools_dir" - exit 0 - ;; - *) - printf 'unexpected xcode-select args: %s\n' "$*" >&2 - exit 1 - ;; -esac -EOF - chmod +x "$TEST_MOCKBIN/xcode-select" - - cat > "$TEST_MOCKBIN/xcrun" <<'EOF' -#!/usr/bin/env bash -state_dir="${BANYAN_SETUP_TEST_STATE_DIR:?}" -installed_file="$state_dir/xcode-installed" - -if [[ "${1:-}" == "-f" && "${2:-}" == "clang" && -f "$installed_file" ]]; then - printf '/usr/bin/clang\n' - exit 0 -fi - -exit 1 -EOF - chmod +x "$TEST_MOCKBIN/xcrun" -} - -create_brew_stub() { - cat > "$TEST_MOCKBIN/brew" <<'EOF' -#!/usr/bin/env bash -state_dir="${BANYAN_SETUP_TEST_STATE_DIR:?}" -python_prefix="${BANYAN_SETUP_TEST_PYTHON_PREFIX:?}" -python_formula="${BANYAN_SETUP_PYTHON_FORMULA:-python@3.13}" -bats_formula="${BANYAN_SETUP_BATS_FORMULA:-bats-core}" - -case "${1:-}" in - list) - case "${2:-}" in - "$python_formula") - [[ -f "$state_dir/python-installed" ]] - exit $? - ;; - "$bats_formula") - [[ -f "$state_dir/bats-installed" ]] - exit $? - ;; - esac - exit 1 - ;; - install) - if [[ "${2:-}" == "$python_formula" ]]; then - touch "$state_dir/python-install-ran" - touch "$state_dir/python-installed" - mkdir -p "$python_prefix/bin" - cat > "$python_prefix/bin/python3" <<'PYEOF' -#!/usr/bin/env bash -if [[ "${1:-}" == "-m" && "${2:-}" == "venv" && -n "${3:-}" ]]; then - mkdir -p "$3/bin" - printf 'python-home = test\n' > "$3/pyvenv.cfg" - printf '#!/usr/bin/env bash\n' > "$3/bin/activate" - exit 0 -fi -printf 'unexpected python3 args: %s\n' "$*" >&2 -exit 1 -PYEOF - chmod +x "$python_prefix/bin/python3" - exit 0 - fi - if [[ "${2:-}" == "$bats_formula" ]]; then - touch "$state_dir/bats-install-ran" - touch "$state_dir/bats-installed" - exit 0 - fi - printf 'unexpected brew install args: %s\n' "$*" >&2 - exit 1 - ;; - --prefix) - if [[ "${2:-}" == "$python_formula" ]]; then - printf '%s\n' "$python_prefix" - exit 0 - fi - exit 1 - ;; - *) - printf 'unexpected brew args: %s\n' "$*" >&2 - exit 1 - ;; -esac -EOF - chmod +x "$TEST_MOCKBIN/brew" -} - -create_homebrew_installer_stub() { - local installer="$TEST_TMPDIR/homebrew-installer.sh" - - cat > "$installer" <<'EOF' -#!/usr/bin/env bash -touch "${BANYAN_SETUP_TEST_STATE_DIR:?}/homebrew-install-ran" -cat > "${BANYAN_SETUP_TEST_MOCKBIN:?}/brew" <<'BREWEOF' -#!/usr/bin/env bash -state_dir="${BANYAN_SETUP_TEST_STATE_DIR:?}" -python_prefix="${BANYAN_SETUP_TEST_PYTHON_PREFIX:?}" -python_formula="${BANYAN_SETUP_PYTHON_FORMULA:-python@3.13}" -bats_formula="${BANYAN_SETUP_BATS_FORMULA:-bats-core}" - -case "${1:-}" in - list) - case "${2:-}" in - "$python_formula") - [[ -f "$state_dir/python-installed" ]] - exit $? - ;; - "$bats_formula") - [[ -f "$state_dir/bats-installed" ]] - exit $? - ;; - esac - exit 1 - ;; - install) - if [[ "${2:-}" == "$python_formula" ]]; then - touch "$state_dir/python-install-ran" - touch "$state_dir/python-installed" - mkdir -p "$python_prefix/bin" - cat > "$python_prefix/bin/python3" <<'PYEOF' -#!/usr/bin/env bash -if [[ "${1:-}" == "-m" && "${2:-}" == "venv" && -n "${3:-}" ]]; then - mkdir -p "$3/bin" - printf 'python-home = test\n' > "$3/pyvenv.cfg" - printf '#!/usr/bin/env bash\n' > "$3/bin/activate" - exit 0 -fi -printf 'unexpected python3 args: %s\n' "$*" >&2 -exit 1 -PYEOF - chmod +x "$python_prefix/bin/python3" - exit 0 - fi - if [[ "${2:-}" == "$bats_formula" ]]; then - touch "$state_dir/bats-install-ran" - touch "$state_dir/bats-installed" - exit 0 - fi - printf 'unexpected brew install args: %s\n' "$*" >&2 - exit 1 - ;; - --prefix) - if [[ "${2:-}" == "$python_formula" ]]; then - printf '%s\n' "$python_prefix" - exit 0 - fi - exit 1 - ;; - *) - printf 'unexpected brew args: %s\n' "$*" >&2 - exit 1 - ;; -esac -BREWEOF -chmod +x "${BANYAN_SETUP_TEST_MOCKBIN:?}/brew" -EOF - chmod +x "$installer" - - printf '%s\n' "$installer" -} - -run_setup() { - local python_prefix="$TEST_TMPDIR/python-prefix" - local xcode_dir="$TEST_TMPDIR/CommandLineTools" - - run env \ - HOME="$TEST_HOME" \ - PATH="$TEST_MOCKBIN:$TEST_BASH_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin" \ - OSTYPE="${OSTYPE_OVERRIDE:-darwin24}" \ - BANYAN_SETUP_BREW_BIN="$TEST_MOCKBIN/brew" \ - BANYAN_SETUP_TEST_STATE_DIR="$TEST_STATE_DIR" \ - BANYAN_SETUP_TEST_MOCKBIN="$TEST_MOCKBIN" \ - BANYAN_SETUP_TEST_PYTHON_PREFIX="$python_prefix" \ - BANYAN_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$xcode_dir" \ - BANYAN_SETUP_XCODE_WAIT_TIMEOUT_SECONDS=5 \ - BANYAN_SETUP_XCODE_WAIT_INTERVAL_SECONDS=0 \ - "$@" -} - -@test "setup prints usage for help" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" --help - - [ "$status" -eq 0 ] - [[ "$output" == *"Usage:"* ]] - [[ "$output" == *"setup [options] "* ]] - [[ "$output" == *"check"* ]] - [[ "$output" == *"Prepare and verify the local Banyan Labs CLI environment on macOS."* ]] -} - -@test "setup requires an explicit command" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" - - [ "$status" -eq 1 ] - [[ "$output" == *"A setup command is required."* ]] -} - -@test "setup fails on unsupported operating systems" { - OSTYPE_OVERRIDE="linux-gnu" - - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" install - - [ "$status" -eq 1 ] - [[ "$output" == *"supports macOS only"* ]] -} - -@test "setup is idempotent when brew, xcode tools, python, and the venv already exist" { - local venv_dir="$TEST_HOME/.banyanlabs.d/.venv" - - create_brew_stub - create_xcode_stubs - touch "$TEST_STATE_DIR/xcode-installed" - mkdir -p "$TEST_TMPDIR/CommandLineTools" - touch "$TEST_STATE_DIR/python-installed" - touch "$TEST_STATE_DIR/bats-installed" - mkdir -p "$venv_dir/bin" - printf '#!/usr/bin/env bash\n' > "$venv_dir/bin/activate" - - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" install - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew is already installed."* ]] - [[ "$output" == *"Xcode Command Line Tools are already installed."* ]] - [[ "$output" == *"Python formula 'python@3.13' is already installed via Homebrew."* ]] - [[ "$output" == *"BATS formula 'bats-core' is already installed via Homebrew."* ]] - [[ "$output" == *"Virtual environment already exists at '$venv_dir'."* ]] - [ ! -f "$TEST_STATE_DIR/python-install-ran" ] - [ ! -f "$TEST_STATE_DIR/bats-install-ran" ] -} - -@test "setup installs missing dependencies and creates the Banyan virtual environment" { - local installer - local venv_dir="$TEST_HOME/.banyanlabs.d/.venv" - - create_xcode_stubs - installer="$(create_homebrew_installer_stub)" - - run_setup \ - BANYAN_SETUP_ALLOW_NONINTERACTIVE_XCODE_INSTALL=true \ - BANYAN_SETUP_HOMEBREW_INSTALLER_SCRIPT="$installer" \ - "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" install - - [ "$status" -eq 0 ] - [[ "$output" == *"Installing Homebrew."* ]] - [[ "$output" == *"Installing Xcode Command Line Tools."* ]] - [[ "$output" == *"Xcode Command Line Tools installation detected."* ]] - [[ "$output" == *"Installing Python formula 'python@3.13' via Homebrew."* ]] - [[ "$output" == *"Installing BATS formula 'bats-core' via Homebrew."* ]] - [[ "$output" == *"Creating Python virtual environment at '$venv_dir'."* ]] - [[ "$output" == *"Banyan Labs CLI setup is complete."* ]] - [ -f "$TEST_STATE_DIR/homebrew-install-ran" ] - [ -f "$TEST_STATE_DIR/python-install-ran" ] - [ -f "$TEST_STATE_DIR/bats-install-ran" ] - [ -f "$venv_dir/pyvenv.cfg" ] -} - -@test "setup install supports dry-run without making changes" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" install --dry-run - - [ "$status" -eq 0 ] - [[ "$output" == *"[DRY-RUN] Would install Homebrew using the official installer."* ]] - [[ "$output" == *"[DRY-RUN] Would wait for Xcode Command Line Tools installation to complete."* ]] - [[ "$output" == *"[DRY-RUN] Would install Python formula 'python@3.13' via Homebrew."* ]] - [[ "$output" == *"[DRY-RUN] Would install BATS formula 'bats-core' via Homebrew."* ]] - [[ "$output" == *"[DRY-RUN] Would create Python virtual environment at '$TEST_HOME/.banyanlabs.d/.venv'."* ]] - [[ "$output" == *"[DRY-RUN] Banyan Labs CLI setup check is complete."* ]] - [ ! -e "$TEST_HOME/.banyanlabs.d/.venv" ] -} - -@test "setup check passes when all required components are present" { - local venv_dir="$TEST_HOME/.banyanlabs.d/.venv" - - create_brew_stub - create_xcode_stubs - touch "$TEST_STATE_DIR/xcode-installed" - mkdir -p "$TEST_TMPDIR/CommandLineTools" - touch "$TEST_STATE_DIR/python-installed" - touch "$TEST_STATE_DIR/bats-installed" - mkdir -p "$venv_dir/bin" - printf '#!/usr/bin/env bash\n' > "$venv_dir/bin/activate" - - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" check - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew is installed."* ]] - [[ "$output" == *"Xcode Command Line Tools are installed."* ]] - [[ "$output" == *"Python formula 'python@3.13' is installed via Homebrew."* ]] - [[ "$output" == *"BATS formula 'bats-core' is installed via Homebrew."* ]] - [[ "$output" == *"Virtual environment exists at '$venv_dir'."* ]] - [[ "$output" == *"Banyan Labs CLI environment check passed."* ]] -} - -@test "setup check fails when required components are missing" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" check - - [ "$status" -eq 1 ] - [[ "$output" == *"Homebrew is not installed."* ]] - [[ "$output" == *"Xcode Command Line Tools are not installed."* ]] - [[ "$output" == *"Python formula 'python@3.13' is not installed via Homebrew."* ]] - [[ "$output" == *"BATS formula 'bats-core' is not installed via Homebrew."* ]] - [[ "$output" == *"Virtual environment is missing at '$TEST_HOME/.banyanlabs.d/.venv'."* ]] - [[ "$output" == *"Banyan Labs CLI environment check found missing requirements."* ]] -} - -@test "setup install enables DEBUG logs with -v" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" -v install --dry-run - - [ "$status" -eq 0 ] - [[ "$output" == *"DEBUG"* ]] - [[ "$output" == *"Running setup command 'install'"* ]] -} - -@test "setup update-profile is reserved for later work" { - run_setup "$BANYAN_REPO_ROOT/cli/bash/bin/setup.sh" update-profile - - [ "$status" -eq 1 ] - [[ "$output" == *"update-profile"* ]] - [[ "$output" == *"not implemented yet"* ]] -} diff --git a/cli/bash/commands/test_cmd/test_cmd.sh b/cli/bash/commands/test_cmd/test_cmd.sh deleted file mode 100644 index 4653f04..0000000 --- a/cli/bash/commands/test_cmd/test_cmd.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -log_info "I am starting" diff --git a/cli/bash/lib/file/README.md b/cli/bash/lib/file/README.md deleted file mode 100644 index 6516bed..0000000 --- a/cli/bash/lib/file/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# `lib_file.sh` - -File-oriented Bash helpers shared by CLI commands. - -## Dependency - -Source `lib/std/lib_std.sh` before this library so logging and error helpers are available. - -## Public API - -- `update_file_section` - Idempotently add, replace, or remove a marker-delimited block inside a file. - -## Usage - -```bash -source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" -source "/absolute/path/to/cli/bash/lib/file/lib_file.sh" - -update_file_section ~/.bash_profile "# BEGIN APP" "# END APP" \ - "export APP_HOME=/opt/app" \ - "alias appctl='app status'" -``` - -## Behavior Notes - -- Returns success when the target file does not exist and there is nothing to remove. -- Replaces only the first matching marked section when markers already exist. -- Appends the marked block when markers are not present. - -## Tests - -BATS coverage lives in `tests/lib_file.bats`. diff --git a/cli/bash/lib/file/lib_file.sh b/cli/bash/lib/file/lib_file.sh deleted file mode 100644 index 992bd86..0000000 --- a/cli/bash/lib/file/lib_file.sh +++ /dev/null @@ -1,159 +0,0 @@ -# shellcheck shell=bash -# -# lib_file.sh - Bash library of generic file manipulation functions. -# - -# -# update_file_section - Idempotently manages a block of text within a file, -# demarcated by start and end markers. -# -# This function can add, update, or remove a section of text in a file. -# It is designed to be safe to run multiple times. If the section already -# exists, it will be replaced. If it doesn't exist, it will be appended. -# -# Usage: -# update_file_section [options] [content_lines...] -# -# Options: -# -r : Remove the section defined by the markers instead of adding/updating it. -# -# Arguments: -# target_file: The path to the file to be modified. -# start_marker: The exact string that marks the beginning of the section. -# end_marker: The exact string that marks the end of the section. -# content_lines: (Optional) One or more strings, each representing a line of -# content to be placed inside the section. -# -# Examples: -# -# # Add/update a section in .bash_profile -# local commands=("export FOO=bar" "alias myalias='echo hello'") -# update_file_section ~/.bash_profile "# START" "# END" "${commands[@]}" -# -# # Remove the same section -# update_file_section -r ~/.bash_profile "# START" "# END" -# -update_file_section() { - local remove_section=false - local new_content_array=() - - if [[ "$1" == "-r" ]]; then - remove_section=true - shift # consume -r - fi - - if [[ $# -lt 3 ]]; then - log_error "Insufficient arguments." - if [[ "$remove_section" == true ]]; then - log_info "Usage: update_file_section -r " - else - log_info "Usage: update_file_section [new_lines...]" - fi - return 1 - fi - - local target_file="$1" beginning_marker="$2" end_marker="$3" - shift 3 # consume target_file, beginning_marker, end_marker - if [[ "$remove_section" == true ]]; then - if [[ $# -gt 0 ]]; then - log_error "When -r flag is used, no content arguments should be provided." - log_info "Usage: update_file_section -r " - return 1 - fi - else - new_content_array=("$@") # Capture remaining arguments as new_lines - fi - - if [[ ! -f "$target_file" ]]; then - log_debug "Target file '$target_file' does not exist." - return 0 - fi - - log_info "Updating '$target_file'" - local new_content_string="" - if [[ "$remove_section" == false ]]; then - if [[ ${#new_content_array[@]} -gt 0 ]]; then - # Use printf to join array elements with newlines, adding a final newline. - # This ensures proper multi-line insertion. - printf -v new_content_string '%s\n' "${new_content_array[@]}" - fi - fi - - local temp_file - temp_file=$(mktemp "${target_file}.XXXXXX") - if [[ ! -f "$temp_file" ]]; then - log_error "Failed to create temporary file for '$target_file'." - return 1 - fi - - if grep -qF -- "$beginning_marker" "$target_file" && grep -qF -- "$end_marker" "$target_file"; then - if [[ "$remove_section" == true ]]; then - if awk -v START_M="$beginning_marker" -v END_M="$end_marker" ' - BEGIN { in_section = 0 } - $0 == START_M { in_section = 1; next } - $0 == END_M { in_section = 0; next } - { - if (in_section == 0) { - print $0 - } - } - ' "$target_file" > "$temp_file" && mv -f "$temp_file" "$target_file"; then - return 0 - fi - else - # FIX: This awk script now correctly handles multiple sections. It only replaces the first one. - export AWK_NEW_TEXT="$new_content_string" - if awk -v START_M="$beginning_marker" -v END_M="$end_marker" ' - BEGIN { - processed = 0 # 0 = not yet processed, 1 = processing, 2 = done - } - $0 == START_M && processed == 0 { - print START_M - printf "%s", ENVIRON["AWK_NEW_TEXT"] # Insert new content - processed = 1 # We are now inside the section to be replaced - next - } - $0 == END_M && processed == 1 { - print END_M - processed = 2 # We are done with the replacement - next - } - processed != 1 { # Print the line if we are not inside the section being replaced - print $0 - } - ' "$target_file" > "$temp_file" && mv -f "$temp_file" "$target_file"; then - unset AWK_NEW_TEXT - return 0 - fi - unset AWK_NEW_TEXT - fi - - log_error "Failed to process sections in '$target_file'." - rm -f "$temp_file" - return 1 - else - # Markers not found in the file - if [[ "$remove_section" == true ]]; then - rm -f "$temp_file" - return 0 - else - cp "$target_file" "$temp_file" - - if [[ $(tail -c 1 "$temp_file" 2>/dev/null | wc -l) -eq 0 ]]; then - echo "" >> "$temp_file" - fi - - if { - echo "$beginning_marker" - printf "%s" "$new_content_string" - echo "$end_marker" - } >> "$temp_file" && mv -f "$temp_file" "$target_file"; then - return 0 - fi - - log_error "Failed to add new section to '$target_file'." - rm -f "$temp_file" - return 1 - fi - fi -} diff --git a/cli/bash/lib/file/tests/lib_file.bats b/cli/bash/lib/file/tests/lib_file.bats deleted file mode 100644 index 1b78d48..0000000 --- a/cli/bash/lib/file/tests/lib_file.bats +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bats - -load ../../../tests/test_helper.bash - -setup() { - setup_test_tmpdir - source "$BANYAN_BASH_DIR/lib/std/lib_std.sh" - source "$BANYAN_BASH_DIR/lib/file/lib_file.sh" -} - -@test "update_file_section appends a new marked block when markers are absent" { - local target="$TEST_TMPDIR/config.txt" - printf 'line-one' > "$target" - - update_file_section "$target" "# BEGIN" "# END" "first" "second" - - [ "$(cat "$target")" = $'line-one\n# BEGIN\nfirst\nsecond\n# END' ] -} - -@test "update_file_section replaces the first matching section" { - local target="$TEST_TMPDIR/config.txt" - cat <<'EOF' > "$target" -before -# BEGIN -old -# END -after -EOF - - update_file_section "$target" "# BEGIN" "# END" "new" - - [ "$(cat "$target")" = $'before\n# BEGIN\nnew\n# END\nafter' ] -} - -@test "update_file_section removes a marked block with -r" { - local target="$TEST_TMPDIR/config.txt" - cat <<'EOF' > "$target" -before -# BEGIN -remove-me -# END -after -EOF - - update_file_section -r "$target" "# BEGIN" "# END" - - [ "$(cat "$target")" = $'before\nafter' ] -} - -@test "update_file_section is a no-op for a missing target file" { - local target="$TEST_TMPDIR/missing.txt" - - bats_run update_file_section "$target" "# BEGIN" "# END" "value" - - [ "$status" -eq 0 ] - [ ! -e "$target" ] -} - -@test "update_file_section rejects content arguments when removing a section" { - local target="$TEST_TMPDIR/config.txt" - touch "$target" - - bats_run update_file_section -r "$target" "# BEGIN" "# END" "unexpected" - - [ "$status" -eq 1 ] - [[ "$output" == *"When -r flag is used"* ]] -} diff --git a/cli/bash/lib/git/README.md b/cli/bash/lib/git/README.md deleted file mode 100644 index e3ebe55..0000000 --- a/cli/bash/lib/git/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# `lib_git.sh` - -Git helpers for Bash commands that need lightweight repository inspection or update behavior. - -## Dependency - -Source `lib/std/lib_std.sh` before this library so logging and shared error handling are available. - -## Public API - -- `git_update_repo` - Update a repository on branch `master`, optionally allowing tracked changes in one specific path. -- `git_get_current_branch` - Return the current branch name through a caller-provided variable, or `detached head`. -- `check_script_up_to_date` - Check whether a tracked script appears current relative to its configured upstream. - -## Internal Helper - -- `_git_only_path_dirty` - Internal predicate used by `git_update_repo` when an allowed dirty path is provided. - -## Usage - -```bash -source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" -source "/absolute/path/to/cli/bash/lib/git/lib_git.sh" - -branch="" -git_get_current_branch "$PWD" branch -log_info "Current branch: $branch" -``` - -## Behavior Notes - -- `git_update_repo` currently only attempts updates when the checked-out branch is `master`. -- `check_script_up_to_date` treats missing git state, untracked scripts, or missing upstreams as skip conditions rather than hard failures. - -## Tests - -BATS coverage lives in `tests/lib_git.bats`. diff --git a/cli/bash/lib/git/lib_git.sh b/cli/bash/lib/git/lib_git.sh deleted file mode 100644 index c92898b..0000000 --- a/cli/bash/lib/git/lib_git.sh +++ /dev/null @@ -1,255 +0,0 @@ -# shellcheck shell=bash -# -# lib_git.sh: Git operations -# - -# -# Returns success when tracked changes are limited to one repo-relative path. -# -# @param $1 allowed_path Path in repository root that may be dirty (for example "shared"). -# -_git_only_path_dirty() { - local allowed_path="$1" - local status_output line path - - status_output="$(git status --porcelain --untracked-files=no --ignore-submodules=none)" - [[ -z "$status_output" ]] && return 1 - - while IFS= read -r line; do - [[ -z "$line" ]] && continue - path="${line:3}" - if [[ "$path" == *" -> "* ]]; then - path="${path#* -> }" - fi - if [[ "$path" != "$allowed_path" ]]; then - return 1 - fi - done <<< "$status_output" - - return 0 -} - -# -# Safely updates a Git repository and its submodules after checking if the current branch is 'master'. -# -# @param $1 git_repo The path to the local git repository. -# @param $2 allowed_dirty_path Optional repo-relative path that may be dirty. -# -git_update_repo() { - local git_repo="$1" - local allowed_dirty_path="${2:-}" - local git_log - - if [[ -z "$git_repo" ]]; then - log_error "No git repository path provided." - log_info "Usage: update_repo /path/to/repo [allowed_dirty_path]" - return 1 - fi - - if [[ ! -d "$git_repo" ]]; then - log_error "Git repo not found at '$git_repo'" - return 1 - fi - - git_log=$(mktemp -p /tmp) - if ! pushd "$git_repo" > /dev/null; then - # If cd fails, we can't proceed. - return 1 - fi - - # Check if it's a valid git repo - if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then - log_error "'$git_repo' is not a Git repository." - popd >/dev/null || return 1 - return 1 - fi - - # Make sure the current branch is master - local current_branch - current_branch=$(git rev-parse --abbrev-ref HEAD) - if [[ "$current_branch" != "master" ]]; then - log_debug "Current branch of '$git_repo' is '${current_branch}', not 'master'. Skipping update." - popd >/dev/null || return 1 - return 1 - fi - - local dirty=false - if ! git diff --quiet; then - dirty=true - fi - if ! git diff --cached --quiet; then - dirty=true - fi - if [[ "$dirty" == true ]]; then - if [[ -n "$allowed_dirty_path" ]] && _git_only_path_dirty "$allowed_dirty_path"; then - log_debug "Repo '$git_repo' only has tracked changes in '$allowed_dirty_path'; attempting git pull." - else - log_debug "Repo '$git_repo' has local changes; skipping auto-update. Commit or stash to enable git pull." - popd >/dev/null || return 1 - return 0 - fi - fi - - # sometimes git pull throws warnings and we need a second git pull to address it - if ! { git pull || git pull; } >"$git_log" 2>&1; then - log_error "git pull failed on repo '$git_repo'" - [[ -s "$git_log" ]] && log_info_file "$git_log" - popd >/dev/null || return 1 - return 1 - fi - - # it is safe to run submodule commands even if the repo has no submodules - if ! { git submodule init && git submodule sync && git submodule update; } >/dev/null; then - log_error "git submodule update failed on repo '$git_repo'" - [[ -s "$git_log" ]] && log_info_file "$git_log" - popd >/dev/null || return 1 - return 1 - fi - - log_debug "Git repo '$git_repo' updated to latest master" - popd >/dev/null || return 1 - return 0 -} - -# -# Gets the currently checked-out branch of a Git repository without using a subshell. -# -# This function safely checks a directory, determines if it's a Git repository, -# and returns the current branch name via a name reference (nameref). -# -# @param $1 target_dir The path to the directory to check. -# @param $2 result_var_name The name of the variable in the calling scope -# that will receive the output. -# -# Returns: -# - The branch name (e.g., "master", "feature/login") is stored in the result variable. -# - "detached head" if the repository is in a detached HEAD state. -# - An empty string "" if the directory doesn't exist or is not a Git repo. -# - The function itself returns an exit code of 0 on success, 1 on invalid usage. -# -git_get_current_branch() { - local target_dir="$1" - local result_var_name="${2:-}" - - # --- Argument Validation --- - if [[ -z "$target_dir" || -z "$result_var_name" ]]; then - log_error "Usage: get_git_branch " - return 1 - fi - - printf -v "$result_var_name" '%s' "" - - if [[ ! -d "$target_dir" ]]; then - return 1 - fi - - # --- Core Logic without Subshell --- - # Use pushd to change directory and add the current dir to a stack. - # Redirect output to /dev/null to keep it clean. - if ! pushd "$target_dir" > /dev/null; then - # If cd fails, we can't proceed. - return 1 - fi - - # Check if we are inside a Git repository. - if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then - # Not a Git repo, result is already an empty string. - popd >/dev/null || return 1 - return 0 - fi - - # Use 'git symbolic-ref' to get the branch name. - # It's the most reliable way to distinguish a branch from a detached HEAD. - # -q (--quiet) suppresses errors and returns a non-zero exit code on failure. - local branch_name - if branch_name=$(git symbolic-ref --short -q HEAD); then - # Success: We are on a named branch. - printf -v "$result_var_name" '%s' "$branch_name" - else - # Failure: We are in a detached HEAD state. - printf -v "$result_var_name" '%s' "detached head" - fi - - popd >/dev/null || return 1 - return 0 -} - -# -# Checks whether a script appears up to date with its git upstream and logs status. -# -# @param $1 script_path The path to a script file tracked in a git repo. -# -# Returns: -# - 0 if up to date or the check is skipped (no git, no upstream, not a repo). -# - 1 on invalid usage. -# - 2 if the repo is behind its upstream (script may be stale). -# - 3 if the script has local modifications. -# -check_script_up_to_date() { - local script_path="$1" - if [[ -z "$script_path" ]]; then - log_error "Usage: check_script_up_to_date " - return 1 - fi - - if [[ ! -e "$script_path" ]]; then - log_warn "Script '$script_path' not found; skipping latest-version check." - return 0 - fi - - if ! command -v git &> /dev/null; then - log_info "git not available; skipping latest-version check." - return 0 - fi - - local script_dir repo_root prefix rel_path - script_dir=$(dirname "$script_path") - repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) || { - log_info "Not in a git repo; skipping latest-version check." - return 0 - } - prefix=$(git -C "$script_dir" rev-parse --show-prefix 2>/dev/null) || { - log_info "Unable to resolve repo-relative path; skipping latest-version check." - return 0 - } - rel_path="${prefix}$(basename "$script_path")" - - if ! git -C "$repo_root" ls-files --error-unmatch "$rel_path" >/dev/null 2>&1; then - log_info "Script '$rel_path' is not tracked in git; skipping latest-version check." - return 0 - fi - - local dirty=false - if ! git -C "$repo_root" diff --quiet -- "$rel_path"; then - dirty=true - fi - if ! git -C "$repo_root" diff --cached --quiet -- "$rel_path"; then - dirty=true - fi - if [[ "$dirty" == true ]]; then - log_warn "Script '$rel_path' has local modifications; version may not match repo." - fi - - local upstream behind ahead - upstream=$(git -C "$repo_root" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || { - log_info "No upstream branch configured; skipping latest-version check." - return 0 - } - - behind=$(git -C "$repo_root" rev-list --count HEAD.."$upstream" 2>/dev/null) - ahead=$(git -C "$repo_root" rev-list --count "$upstream"..HEAD 2>/dev/null) - if [[ -n "$behind" && "$behind" -gt 0 ]]; then - log_warn "Repository is $behind commit(s) behind $upstream. Script may be out of date." - return 2 - elif [[ -n "$ahead" && "$ahead" -gt 0 ]]; then - log_info "Repository is $ahead commit(s) ahead of $upstream." - else - log_info "Repository is up to date with $upstream." - fi - - if [[ "$dirty" == true ]]; then - return 3 - fi - - return 0 -} diff --git a/cli/bash/lib/git/tests/lib_git.bats b/cli/bash/lib/git/tests/lib_git.bats deleted file mode 100644 index 191d500..0000000 --- a/cli/bash/lib/git/tests/lib_git.bats +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bats - -load ../../../tests/test_helper.bash - -setup() { - setup_test_tmpdir - source "$BANYAN_BASH_DIR/lib/std/lib_std.sh" - source "$BANYAN_BASH_DIR/lib/git/lib_git.sh" -} - -@test "git_get_current_branch returns the current branch name" { - local repo="$TEST_TMPDIR/repo" - local branch="" - - init_git_repo "$repo" - git_get_current_branch "$repo" branch - - [ "$branch" = "master" ] -} - -@test "git_get_current_branch reports detached head" { - local repo="$TEST_TMPDIR/repo" - local branch="" - - init_git_repo "$repo" - printf 'hello\n' > "$repo/README.md" - commit_all "$repo" "Initial commit" - git -C "$repo" checkout --detach >/dev/null 2>&1 - - git_get_current_branch "$repo" branch - - [ "$branch" = "detached head" ] -} - -@test "git_update_repo skips dirty repositories when no dirty path is allowed" { - local repo="$TEST_TMPDIR/repo" - - init_git_repo "$repo" - printf 'base\n' > "$repo/data.txt" - commit_all "$repo" "Initial commit" - printf 'local change\n' > "$repo/data.txt" - set_log_level DEBUG - - bats_run git_update_repo "$repo" - - [ "$status" -eq 0 ] - [[ "$output" == *"has local changes; skipping auto-update"* ]] -} - -@test "check_script_up_to_date reports success for an up-to-date tracked script" { - local repo="$TEST_TMPDIR/repo" - local remote="$TEST_TMPDIR/remote.git" - local script_path="$repo/scripts/tool.sh" - - create_tracked_repo_with_upstream "$repo" "$remote" "scripts/tool.sh" "#!/usr/bin/env bash" - - bats_run check_script_up_to_date "$script_path" - - [ "$status" -eq 0 ] - [[ "$output" == *"Repository is up to date with origin/master."* ]] -} - -@test "check_script_up_to_date returns 3 for a dirty tracked script" { - local repo="$TEST_TMPDIR/repo" - local remote="$TEST_TMPDIR/remote.git" - local script_path="$repo/scripts/tool.sh" - - create_tracked_repo_with_upstream "$repo" "$remote" "scripts/tool.sh" "#!/usr/bin/env bash" - printf 'echo dirty\n' >> "$script_path" - - bats_run check_script_up_to_date "$script_path" - - [ "$status" -eq 3 ] - [[ "$output" == *"has local modifications"* ]] -} diff --git a/cli/bash/lib/std/README.md b/cli/bash/lib/std/README.md deleted file mode 100644 index 50513ed..0000000 --- a/cli/bash/lib/std/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# `lib_std.sh` - -Shared foundation library for Bash code under `cli/bash`. - -## What It Provides - -- Bash version checking and one-time initialization when sourced -- Shared globals for callers: `__SCRIPT_ARGS__` and `__SCRIPT_DIR__` -- Library importing with `import` -- PATH helpers: `add_to_path`, `dedupe_path`, `print_path` -- Structured logging with `set_log_level`, `log_*`, and `print_*` -- Failure helpers: `exit_if_error`, `fatal_error`, `dump_trace` -- Safe command execution via `run` -- Filesystem helpers such as `safe_mkdir`, `safe_touch`, `safe_truncate`, `safe_cd` -- Validation helpers such as `assert_not_null`, `assert_integer`, `assert_integer_range`, `assert_arg_count` -- Small interactive helpers such as `ask_yes_no` and `wait_for_enter` - -## Usage - -Standalone script usage: - -```bash -source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" - -add_to_path -p "/opt/my-tools/bin" -set_log_level DEBUG -run echo "hello" -``` - -## Notes - -- Requires Bash 4.0 or newer. -- Sourcing the file runs `__stdlib_init__`. -- `cli/bash/bin/bash-wrapper` preloads this library for command scripts so commands do not need per-command stdlib sourcing boilerplate. -- The wrapper sets `BANYAN_BASH_BOOTSTRAP_SOURCE` before sourcing this file so `__SCRIPT_DIR__` still points at the command script rather than the wrapper. -- Wrapper-level flags such as `--debug-wrapper` and `--verbose-wrapper` are consumed during initialization. -- Other Bash libraries in this tree rely on this file for logging and error handling. - -## Tests - -BATS coverage lives in `tests/lib_std.bats`. diff --git a/cli/bash/lib/std/lib_std.sh b/cli/bash/lib/std/lib_std.sh deleted file mode 100644 index 8a32a0d..0000000 --- a/cli/bash/lib/std/lib_std.sh +++ /dev/null @@ -1,1156 +0,0 @@ -# shellcheck shell=bash -# -# lib_std.sh - Foundation library for Bash scripts -# Requires Bash version 4.0 or higher. -# -# This library provides a standardized set of functions for common tasks, -# ensuring consistency and robustness across multiple scripts. -# -# Areas covered: -# - PATH manipulation -# - Logging (with levels and colors) -# - Error handling and stack tracing -# - Bash version checking -# - Library importing -# - Miscellaneous helpers -# -# Quick Reference -# -------------------------------------------------------------------------------------------------------------------- -# Sourcing: -# source "/cli/bash/lib/std/lib_std.sh" -# -# Caller-visible globals: -# __SCRIPT_ARGS__ Original "$@" before lib_std consumed global flags. -# __SCRIPT_DIR__ Absolute path to the script that sourced the library. -# -# Core helpers: -# run [--no-exit] cmd ... # Safe command runner with dry-run & failure handling. -# exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status). -# fatal_error msg... # Convenience wrapper: exit with last status or 1. -# add_to_path [-n] [-p] dir # Append/prepend unique PATH entries. -# set_log_level [LEVEL] # Adjust default logger (FATAL..VERBOSE). -# log_info/debug/... msgs # Structured logging (color in interactive shells). -# safe_touch file [...] # touch wrapper that exits on failure (same for safe_truncate). -# assert_* utilities # Validation helpers (assert_not_null / assert_integer / ...). -# -# Patterns: -# run some_cmd # exits on failure; DRY_RUN=true prints instead. -# some_cmd || fatal_error ... # preserves failing exit code before terminating. -# add_to_path -p "/opt/tools" # inject directories without duplicates. -# -# Notes: -# - Global options --debug-wrapper/--verbose-wrapper/--utc-wrapper/--color are stripped from "$@" automatically. -# - Wrappers may override the caller path seen by this library through BANYAN_BASH_BOOTSTRAP_SOURCE. -# - -################################################# INITIALIZATION ####################################################### - -# -# Make sure we do nothing in case the library is sourced more than once in the same shell. -# This prevents functions from being redefined and initialization from running multiple times. -# -[[ -n "${__stdlib_sourced__-}" ]] && return -__stdlib_sourced__=1 -readonly __LIB_STD_PATH__="${BASH_SOURCE[0]}" - -# -# Memorize the original script arguments at the very beginning. -# This allows the library to parse global options before the main script does. -# We retain the original arguments in __SCRIPT_ARGS__ and the script source directory in __SCRIPT_DIR__ as readonly -# variables which could be used by the caller. When a wrapper preloads this library on behalf of another script, it can -# provide BANYAN_BASH_BOOTSTRAP_SOURCE so __SCRIPT_DIR__ still resolves to the real command script. -# -readonly __SCRIPT_ARGS__=("$@") -__new_args__=() -__SCRIPT_DIR__=$( - cd -- "$(dirname -- "${BANYAN_BASH_BOOTSTRAP_SOURCE:-${BASH_SOURCE[1]}}" )" &>/dev/null && pwd -P -) -readonly __SCRIPT_DIR__ - -############################################ BASH VERSION CHECKER ####################################################### - -# -# is_interactive - Checks if the current shell is interactive. -# -# An interactive shell is one where the user is typing commands directly. -# This is used to determine if we can safely prompt the user for input. -# -# Returns: -# 0 (true) if the shell is interactive. -# 1 (false) if the shell is not interactive (e.g., running in a cron job). -# -is_interactive() { - [[ -t 0 ]] -} - -# -# check_bash_version_and_upgrade - Verifies the Bash version and prompts for an upgrade if necessary. -# -# This function checks if the running Bash interpreter is version 4.0 or higher. -# If the version is too old and the shell is interactive, it will offer to -# install/upgrade Bash via Homebrew. If the shell is not interactive, or if the OSTYPE is not darwin, -# it will exit with an error. -# -# Note: This function is called before logging is initialized, so it uses `echo` to stderr. -# -check_bash_version_and_upgrade() { - local -r major_version=${BASH_VERSINFO[0]} - if ((major_version < 4)); then - if ! is_interactive; then - { - echo "Error: This script requires Bash 4.0 or higher." - echo "Your version ($BASH_VERSION) is not compatible." - echo "Upgrade Bash manually or run the script in interactive mode for guided upgrade." - } >&2 - exit 1 - fi - - # -- Interactive Upgrade Process -- - echo "Warning: This script requires Bash version 4.0 or higher to run correctly." >&2 - echo "Your current version is $BASH_VERSION." >&2 - - local install_cmd - if [[ "$OSTYPE" == "linux-gnu"* ]]; then - if command -v apt-get &>/dev/null; then - install_cmd="sudo apt-get update && sudo apt-get install bash" - elif command -v yum &>/dev/null; then - install_cmd="sudo yum install bash" - fi - - echo "On your system, you can likely upgrade by running:" >&2 - echo " $install_cmd" >&2 - exit 1 - elif [[ "$OSTYPE" == "darwin"* ]]; then - read -p "Would you like to attempt an upgrade using Homebrew? (y/n) " -n 1 -r - echo >&2 - if [[ $REPLY =~ ^[Yy]$ ]]; then - if ! command -v brew &>/dev/null; then - echo "Homebrew is not installed." >&2 - read -p "May I install Homebrew for you? (y/n) " -n 1 -r - echo >&2 - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "Installing Homebrew..." >&2 - if ! /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then - echo "Error: Homebrew installation failed. Please install it manually and try again." >&2 - exit 1 - fi - echo "Homebrew installed successfully." >&2 - else - echo "Aborting. Homebrew is required to proceed." >&2 - exit 1 - fi - fi - - echo "Updating Homebrew and installing Bash..." >&2 - if ! { brew update && brew install bash; }; then - echo "Error: Failed to install Bash via Homebrew." >&2 - exit 1 - fi - - echo "Bash installed successfully." >&2 - - local new_bash_path - new_bash_path="$(brew --prefix)/bin/bash" - if [[ -f "$new_bash_path" ]]; then - printf 'Relaunching script with the new Bash from: %s' "$new_bash_path" >&2 - printf ' %q' "${__SCRIPT_ARGS__[@]}" >&2 - printf '\n' >&2 - exec "$new_bash_path" "$0" "${__SCRIPT_ARGS__[@]}" - else - echo "Error: Could not find the new Bash executable at '$new_bash_path'." >&2 - exit 1 - fi - else - echo "Aborting. Please upgrade Bash to version 4.0 or higher to run this script." >&2 - exit 1 - fi - else - echo "Unsupported OSTYPE: [$OSTYPE]" >&2 - exit 1 - fi - fi -} - -###################################################### INIT ############################################################ - -# -# __stdlib_init__ - The main initialization function for this library. -# -# This is the only function that executes when the library is sourced. -# It sets up the environment by: -# 1. Checking the Bash version. -# 2. Initializing the logging system. -# 3. Parsing global command-line options like --debug, --verbose, --color. -# -__stdlib_init__() { - check_bash_version_and_upgrade - __log_init__ - - # - # Handle global arguments and strip them from the list before passing control to the main script. - # The environment variables LOG_DEBUG and LOG_UTC are recognized by the Python logging framework: - # - LOG_DEBUG=1 sets the log level to DEBUG (Bash logging has VERBOSE but Python has only DEBUG) - # - LOG_UTC=1 forces timestamps to use UTC - # - local arg - __color__=0 - for arg in "${__SCRIPT_ARGS__[@]}"; do - case "$arg" in - --debug-wrapper) - set_log_level DEBUG - export LOG_DEBUG=1 - ;; - --verbose-wrapper) - set_log_level VERBOSE - export LOG_DEBUG=1 - ;; - --utc-wrapper) - export LOG_UTC=1 - ;; - --color) - __color__=1 - ;; - *) - __new_args__+=("$arg") - ;; - esac - done - __init_colors__ - log_debug "Command line: $0 ${__SCRIPT_ARGS__[*]}" - return 0 -} - -################################################# LIBRARY IMPORTER ##################################################### - -# -# import - Sources one or more other library files. -# -# This function provides a robust way to include other shell libraries. It handles -# both absolute and relative paths. Relative paths are resolved from the directory -# of the main script that sourced this library. -# -# Usage: -# import /path/to/absolute/lib.sh -# import relative/path/to/lib2.sh -# -# IMPORTANT NOTE: If your library has global variables declared with 'declare', -# you must add the -g flag (e.g., `declare -gA my_map`). Since the library is -# sourced inside this function, globals declared without -g would become local -# to the function and be unavailable to other functions. -# -import() { - local lib - for lib; do - local pushed=0 - # Unless an absolute library path is given, make it relative to the script's location - if [[ "$lib" != /* ]]; then - [[ $__SCRIPT_DIR__ ]] || { printf '%s\n' "ERROR: __SCRIPT_DIR__ not set; import functionality needs it" >&2; exit 1; } - pushd "$__SCRIPT_DIR__" >/dev/null || exit_if_error 1 "Failed to enter script directory '$__SCRIPT_DIR__'." - pushed=1 - fi - if [[ -f "$lib" ]]; then - source "$lib" - exit_if_error $? "Import of library '$lib' not successful." - if ((pushed)); then - popd >/dev/null || exit_if_error 1 "Failed to leave script directory '$__SCRIPT_DIR__'." - fi - else - exit_if_error 1 "Library '$lib' does not exist" - fi - done - return 0 -} - -################################################# PATH MANIPULATION #################################################### - -# -# add_to_path - Adds one or more directories to the system PATH. -# -# This function safely adds directories to the PATH, avoiding duplicates. -# -# Usage: -# add_to_path [options] /path/to/dir1 /path/to/dir2 ... -# -# Options: -# -p : Prepend the directory to the PATH instead of appending. -# -n : Do not check if the directory exists before adding it. -# -add_to_path() { - local dir prepend=0 opt strict=1 - local -a path_dirs - OPTIND=1 - while getopts np opt; do - case "$opt" in - n) strict=0 ;; # don't care if directory exists or not before adding it to PATH - p) prepend=1 ;; # prepend the directory to PATH instead of appending - *) log_error "add_to_path: invalid option '$opt'" - return 1 - ;; - esac - done - - shift $((OPTIND-1)) - - for dir; do - local in_path=0 - ((strict)) && [[ ! -d $dir ]] && continue - IFS=: read -ra path_dirs <<< "$PATH" - for path_dir in "${path_dirs[@]}"; do - if [[ "$path_dir" == "$dir" ]]; then - in_path=1 - break - fi - done - - if ((! in_path)); then - ((prepend)) && PATH="$dir:$PATH" || PATH="$PATH:$dir" - fi - done - - # It's good practice to de-duplicate the path after adding to it - dedupe_path - return 0 -} - -# -# dedupe_path - Removes duplicate entries from the PATH variable. -# -dedupe_path() { - local -A seen - local IFS=':' new_path dir - for dir in $PATH; do - if [[ -n "$dir" && -z "${seen[$dir]}" ]]; then - new_path="${new_path:+$new_path:}$dir" - seen["$dir"]=1 - fi - done - PATH="$new_path" -} - -# -# print_path - Prints each directory in the PATH on a new line. -# -print_path() { - local IFS=':' dirs dir - IFS=: read -ra dirs <<< "$PATH" - for dir in "${dirs[@]}"; do printf '%s\n' "$dir"; done -} - -#################################################### LOGGING ########################################################### - -# -# __log_init__ - Initializes the logging system. -# -# Sets up colors for interactive terminals and defines the log level hierarchy. -# This is called automatically by __stdlib_init__. -# -__log_init__() { - # Map log level strings (FATAL, ERROR, etc.) to numeric values. - # Note the '-g' option passed to declare is essential for global scope. - unset _log_levels _loggers_level_map - declare -gA _log_levels _loggers_level_map - _log_levels=([FATAL]=0 [ERROR]=1 [WARN]=2 [INFO]=3 [DEBUG]=4 [VERBOSE]=5) - - # Hash to map loggers to their log levels. - # The default logger "default" has INFO as its default log level. - _loggers_level_map["default"]=3 -} - -# -# __join_message__ - Join message fragments with a stable single-space separator. -# -__join_message__() { - local IFS=' ' - printf '%s' "$*" -} - -# -# __init_colors__ - Initialize colors used for logging -# This is called from __stdlib_init__ -# -__init_colors__() { - # If --color was not passed, or if the output is not a terminal, disable colors. - if [[ "$__color__" != 1 || ! -t 1 ]]; then - COLOR_BOLD="" - COLOR_RED="" - COLOR_GREEN="" - COLOR_YELLOW="" - COLOR_BLUE="" - COLOR_OFF="" - else - # colors for logging in interactive mode - COLOR_BOLD="\033[1m" - COLOR_RED="\033[0;31m" - COLOR_GREEN="\033[0;32m" - COLOR_YELLOW="\033[0;33m" - COLOR_BLUE="\033[0;36m" - COLOR_OFF="\033[0m" - fi - readonly COLOR_BOLD COLOR_RED COLOR_GREEN COLOR_YELLOW COLOR_BLUE COLOR_OFF -} - -# -# set_log_level - Sets the logging verbosity for a given logger. -# -# Usage: -# set_log_level [level] -# set_log_level -l [logger_name] [level] -# -# Arguments: -# level: One of FATAL, ERROR, WARN, INFO, DEBUG, VERBOSE. Default is INFO. -# -l logger_name: (Optional) Specify a named logger. Default is 'default'. -# -set_log_level() { - local logger=default in_level l - if [[ "${1-}" == "-l" ]]; then - if [[ -z "${2-}" ]]; then - printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 - return 0 - fi - logger=$2 - shift 2 2>/dev/null - fi - in_level="${1:-INFO}" - if [[ $logger ]]; then - l="${_log_levels[$in_level]}" - if [[ $l ]]; then - _loggers_level_map[$logger]=$l - else - printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Unknown log level '$in_level' for logger '$logger'; setting to INFO" >&2 - _loggers_level_map[$logger]=3 - fi - else - printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ - "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 - fi -} - -# -# _print_log - Core and private log printing logic. -# -# This is the internal engine for the logging functions. It formats the log -# message with a timestamp, log level, and source location. It should not -# be called directly; use the `log_*` helper functions instead. -# -_print_log() { - local in_level="${1-}" - [[ -n "$in_level" ]] || return 1 - shift - local logger=default log_level_set log_level color - if [[ "${1-}" == "-l" ]]; then - if [[ -z "${2-}" ]]; then - printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 - return 1 - fi - logger=$2 - shift 2 - fi - log_level="${_log_levels[$in_level]}" - log_level_set="${_loggers_level_map[$logger]:-3}" - - if ((log_level_set >= log_level)); then - # Select color based on log level - case "$in_level" in - FATAL|ERROR) color="$COLOR_RED";; - WARN) color="$COLOR_YELLOW";; - INFO) color="$COLOR_GREEN";; - DEBUG) color="$COLOR_BLUE";; - *) color="";; # No color for VERBOSE or others - esac - - local source_path="" source_line="" frame=1 caller_info caller_line _caller_func caller_file - while caller_info=$(caller "$frame"); do - read -r caller_line _caller_func caller_file <<<"$caller_info" - if [[ -n "$caller_file" && "$caller_file" != "$__LIB_STD_PATH__" ]]; then - source_path="$caller_file" - source_line="$caller_line" - break - fi - ((frame++)) - done - - if [[ -z "$source_path" ]]; then - source_path="${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-unknown}}}" - source_line="${BASH_LINENO[1]:-${BASH_LINENO[0]:-0}}" - fi - - source_path="${source_path#"$__SCRIPT_DIR__"/}" - source_path="${source_path#./}" - - local message - message="$(__join_message__ "$@")" - { - printf '%b' "$color" - printf '%(%Y-%m-%d %H:%M:%S)T %-7s %s ' -1 "$in_level" "${source_path}:${source_line}" - printf '%s' "$message" - printf '%b\n' "$COLOR_OFF" - } >&2 - fi -} - -# -# _print_log_file - Core function for logging the contents of a file. -# -# Internal helper to be called by `log_info_file`, etc. -# -_print_log_file() { - local in_level="${1-}" - [[ -n "$in_level" ]] || return 1 - shift - local logger=default log_level_set log_level file - if [[ "${1-}" == "-l" ]]; then - if [[ -z "${2-}" ]]; then - printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 - return 1 - fi - logger=$2 - shift 2 - fi - file="${1-}" - log_level="${_log_levels[$in_level]}" - log_level_set="${_loggers_level_map[$logger]}" - if [[ $log_level_set ]]; then - if ((log_level_set >= log_level)) && [[ -f $file ]]; then - _print_log "$in_level" -l "$logger" "Contents of file '$file':" - cat -- "$file" >&2 - fi - else - printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[2]}:${BASH_LINENO[1]} Unknown logger '$logger'" >&2 - fi -} - -# -# Public logging functions. -# These are the primary functions scripts should use for logging. -# -log_fatal() { _print_log FATAL "$@"; } -log_error() { _print_log ERROR "$@"; } -log_warn() { _print_log WARN "$@"; } -log_info() { _print_log INFO "$@"; } -log_debug() { _print_log DEBUG "$@"; } -log_verbose() { _print_log VERBOSE "$@"; } - -# -# Public functions for logging the content of a file. -# -log_info_file() { _print_log_file INFO "$@"; } -log_debug_file() { _print_log_file DEBUG "$@"; } -log_verbose_file() { _print_log_file VERBOSE "$@"; } - -# -# Public functions for logging function entry and exit points. -# -log_info_enter() { _print_log INFO "Entering function ${FUNCNAME[1]}"; } -log_debug_enter() { _print_log DEBUG "Entering function ${FUNCNAME[1]}"; } -log_verbose_enter() { _print_log VERBOSE "Entering function ${FUNCNAME[1]}"; } -log_info_leave() { _print_log INFO "Leaving function ${FUNCNAME[1]}"; } -log_debug_leave() { _print_log DEBUG "Leaving function ${FUNCNAME[1]}"; } -log_verbose_leave() { _print_log VERBOSE "Leaving function ${FUNCNAME[1]}"; } - -# -# Simple print routines that do not prefix messages with timestamps or levels. -# -print_error() { local message; message="$(__join_message__ "$@")"; { printf '%bERROR: %s%b\n' "$COLOR_RED" "$message" "$COLOR_OFF"; } >&2; } -print_warn() { local message; message="$(__join_message__ "$@")"; { printf '%bWARN: %s%b\n' "$COLOR_YELLOW" "$message" "$COLOR_OFF"; } >&2; } -print_info() { local message; message="$(__join_message__ "$@")"; { printf '%b%s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } -print_success() { local message; message="$(__join_message__ "$@")"; { printf '%bSUCCESS: %s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } -print_bold() { local message; message="$(__join_message__ "$@")"; printf '%b%s%b\n' "$COLOR_BOLD" "$message" "$COLOR_OFF"; } -print_message() { printf '%s\n' "$@"; } - -# -# print_tty - Prints a message only if the output is going to a terminal. -# -print_tty() { - if [[ -t 1 ]]; then - printf '%s\n' "$(__join_message__ "$@")" - fi -} - -################################################## ERROR HANDLING ###################################################### - -# -# dump_trace - Prints a stack trace of the Bash function calls. -# -# This is useful for debugging to see the sequence of function calls -# that led to an error. -# -dump_trace() { - local frame=0 line func source n=0 - while caller "$frame"; do - ((frame++)) - done | while read -r line func source; do - ((n++ == 0)) && { - printf 'Encountered a fatal error\n' - } - printf '%4s at %s\n' " " "$func ($source:$line)" - done >&2 -} - -# -# exit_if_error - Exits the script if the provided exit code is non-zero. -# -# This is the primary error handling function. It checks a command's exit -# code and, if it indicates failure, logs a fatal message, dumps a stack -# trace, and exits the script. -# -# Usage: -# command_that_might_fail -# exit_if_error $? "A descriptive error message." -# -# Arguments: -# $1: The exit code to check (typically $?). -# $@: The error message to log if the exit code is non-zero. -# -exit_if_error() { - (($#)) || return - local num_re='^[0-9]+$' - local rc=$1; shift - local message - if (($#)); then - message="$(__join_message__ "$@")" - else - message="No message specified" - fi - if ! [[ $rc =~ $num_re ]]; then - log_error "'$rc' is not a valid exit code; it needs to be a number greater than zero. Treating it as 1." - rc=1 - fi - ((rc)) && { - log_fatal "$message" - dump_trace - exit "$rc" - } - return 0 -} - -# -# fatal_error - A convenience wrapper around exit_if_error. -# -# This function immediately triggers a fatal error, using the exit code -# of the last command if it was non-zero, or 1 otherwise. -# -# Usage: -# [[ -f "$my_file" ]] || fatal_error "Required file '$my_file' not found." -# -fatal_error() { - local ec=$? # grab the current exit code - ((ec == 0)) && ec=1 # if it is zero, set exit code to 1 - exit_if_error "$ec" "$@" -} - -#################################################### COMMAND EXECUTION ################################################# - -# -# run - Safely executes a simple command with its arguments. -# -# This function is designed to be a secure and robust replacement for using -# `eval` or simple command execution. It correctly handles arguments with -# spaces and special characters. -# -# Features: -# - Secure: Does not use `eval`, preventing arbitrary code execution. -# - Argument Safe: Correctly handles spaces and special characters in arguments. -# - Dry-Run Mode: If the global variable DRY_RUN (or dry_run) is true, it prints the -# command instead of running it. -# - Exit on Failure: By default, it will exit the script if the command -# returns a non-zero exit code. -# - Optional No-Exit: If the first argument is `--no-exit`, the function -# will not exit on failure, allowing the calling script to handle the error. -# -# Usage: -# run [options] command [arg1] [arg2] ... -# -# Options: -# --no-exit If provided as the very first argument, the script will not -# exit if the command fails. The function will return the -# command's original exit code. -# -# Examples: -# # Run a simple command. Exits if `ls` fails. -# run ls -l /tmp -# -# # Run a command with spaces in an argument. -# run touch "a file with spaces.txt" -# -# # Run a command but don't exit the script on failure. -# if ! run --no-exit grep "not_found" /etc/hosts; then -# log "INFO" "The text was not found, but we are continuing." -# fi -# -# # In a script where DRY_RUN=true, this will only print the command. -# DRY_RUN=true -# run rm -rf /some/important/path -# -################################################################################ -run() { - local exit_on_failure=1 - - # Check for the optional --no-exit flag. - if [[ "${1-}" == "--no-exit" ]]; then - exit_on_failure=0 - shift # Remove the --no-exit flag from the arguments list. - fi - - # Check if the command is empty. - if [[ $# -eq 0 ]]; then - log_error "run: No command provided." - return 1 - fi - - local printable_command - printf -v printable_command "%q " "$@" - printable_command="${printable_command% }" - - # --- Dry-Run Handling --- - if [[ "${DRY_RUN-}" == true || "${dry_run-}" == true ]]; then - # Use printf with the %q format specifier. This is the safest way to - # print a command and its arguments in a way that is unambiguous and - # could be copied and pasted back into a shell. - log_info "[DRY-RUN] Would run: ${printable_command}" - return 0 - fi - - # --- Execution --- - # Execute the command. Using "$@" is the key. It expands each argument - # as a separate, quoted string, preserving spaces and special characters. - # This is the safe, modern alternative to using `eval`. - "$@" - local exit_code=$? - if ((exit_code)); then - if ((exit_on_failure)); then - exit_if_error "$exit_code" "Command failed (exit $exit_code): ${printable_command}" - else - log_warn "Command failed (exit $exit_code): ${printable_command} (continuing)." - return $exit_code - fi - fi - - return 0 -} - -############################################## FILE AND DIRECTORY HANDLING ############################################ - -# -# safe_mkdir: Attempt to create directories and exit on failure. -# Creates as many directories as possible. -# -# Usage: safe_mkdir [-p] dir1 dir2 ... -# -safe_mkdir() { - local dir failed_dirs=() mkdir_args=() - if [[ "${1-}" == "-p" ]]; then - shift - mkdir_args=(-p) - fi - for dir; do - [[ -d "$dir" ]] && continue - if ! mkdir "${mkdir_args[@]}" -- "$dir"; then - failed_dirs+=("$dir") - fi - done - ((${#failed_dirs[@]} > 0)) && exit_if_error 1 "Failed to create directories: ${failed_dirs[*]}" - return 0 -} - -# -# safe_touch - Creates or updates the timestamp of one or more files. -# -# This function iterates through all provided file paths. It attempts to -# 'touch' each file. If any operation fails (e.g., due to permissions), -# it collects the names of the failed files and reports them all in a -# single fatal error at the end. -# -# Usage: -# safe_touch "/tmp/file1.log" "/var/run/app.pid" -# -# Arguments: -# $@: One or more file paths to touch. -# -safe_touch() { - local failed_files=() - local file - - if (($# == 0)); then - log_warn "safe_touch: No files provided to touch." - return 0 - fi - - for file; do - if ! touch "$file" 2>/dev/null; then - failed_files+=("$file") - fi - done - - if ((${#failed_files[@]} > 0)); then - fatal_error "Failed to touch the following files: ${failed_files[*]}" - fi - - return 0 -} - -# -# safe_truncate - Truncates one or more files to zero bytes. -# -# This function iterates through all provided file paths. It attempts to -# truncate each file. If any operation fails (e.g., due to permissions), -# it collects the names of the failed files and reports them all in a -# single fatal error at the end. -# -# Usage: -# safe_truncate "/var/log/app.log" "/tmp/data.tmp" -# -# Arguments: -# $@: One or more file paths to truncate. -# -safe_truncate() { - local failed_files=() - local file - - if (($# == 0)); then - log_warn "safe_truncate: No files provided to truncate." - return 0 - fi - - for file; do - # The > redirection is the simplest way to truncate a file. - # We redirect stderr to /dev/null to suppress system error messages, - # as we will provide our own comprehensive error message. - if ! : > "$file" 2>/dev/null; then - failed_files+=("$file") - fi - done - - if ((${#failed_files[@]} > 0)); then - fatal_error "Failed to truncate the following files: ${failed_files[*]}" - fi - - return 0 -} - -####################################################### ASSERTIONS #################################################### - -# -# assert_not_null - Checks that one or more variables are not empty. -# -# This function takes the *name* of one or more variables and checks that -# each one has a non-empty value. It is useful for validating required -# script inputs or configuration variables. Unlike other assertions, it -# checks all provided variables and reports all failures at once. -# -# Usage: -# USER="admin" -# TOKEN="" -# assert_not_null USER # This will succeed. -# assert_not_null USER TOKEN # This will fail, listing TOKEN as empty. -# -# Arguments: -# $@: One or more variable names to check. -# -assert_not_null() { - local unset_vars=() var_name - if (($# == 0)); then - fatal_error "assert_not_null: No variable names provided for validation." - fi - - for var_name in "$@"; do - # Use indirection to get the value of the variable whose name is stored in var_name. - # The -v check is for unset variables, -z is for empty strings. - # We check for empty string as per the request. - if [[ ! -v $var_name || -z "${!var_name-}" ]]; then - unset_vars+=("$var_name") - fi - done - - if ((${#unset_vars[@]} > 0)); then - fatal_error "These required variables are not set or are empty: ${unset_vars[*]}" - fi - - return 0 -} - -# -# assert_integer - Checks if the values of one or more variables are valid integers. -# -assert_integer() { - local var_name int_re='^[-+]?[0-9]+$' - (($# == 0)) && fatal_error "assert_integer: No variable names provided." - for var_name in "$@"; do - local value="${!var_name-}" - ! [[ "$value" =~ $int_re ]] && fatal_error "Variable '$var_name' with value '$value' is not a valid integer." - done - return 0 -} - -# -# assert_integer_range - Checks if a variable's value is an integer within a specified range. -# -# Arguments: -# $1: The NAME of the variable to check. -# $2: The minimum value. -# $3: The maximum value. -# -assert_integer_range() { - local var_name="${1-}" min="${2-}" max="${3-}" - (($# != 3)) && fatal_error "assert_integer_range: Expected 3 arguments, got $#." - local value="${!var_name-}" - assert_integer "$var_name" min max - ((value < min || value > max)) && fatal_error "Variable '$var_name' ($value) is out of range [$min, $max]." - return 0 -} - -# -# assert_arg_count - Checks that the number of arguments falls within a given range. -# -# Usage: -# assert_arg_count $# 2 # Fails if arg count is not exactly 2 -# assert_arg_count $# 1 3 # Fails if arg count is not between 1 and 3 (inclusive) -# -# Arguments: -# $1: The actual number of arguments (typically $#). -# $2: The exact expected count, or the minimum count for a range. -# $3: (Optional) The maximum count for a range. -# -assert_arg_count() { - local arg_count="${1-}" count1="${2-}" count2="${3-}" argc=$# - - # Check the number of arguments passed to this function itself. - if ((argc < 2 || argc > 3)); then - fatal_error "assert_arg_count: Incorrect usage. Expected 2 or 3 arguments, but got $argc." - fi - - # Create temporary named variables for assert_integer to check - local __assert_arg_count_val="$arg_count" __assert_count1_val="$count1" - assert_integer __assert_arg_count_val __assert_count1_val - - if [[ -n "$count2" ]]; then - local __assert_count2_val="$count2" - assert_integer __assert_count2_val - fi - - if [[ -z "$count2" ]]; then - # Exact match case - if ((arg_count != count1)); then - fatal_error "Argument count mismatch: expected $count1 but got $arg_count arguments" - fi - else - # Range match case - if ((arg_count < count1 || arg_count > count2)); then - fatal_error "Argument count mismatch: expected between $count1 and $count2 arguments, but got $arg_count" - fi - fi - return 0 -} - -# -# assert_command_exists - Checks that one or more commands are available in the system's PATH. -# -# This function iterates through all provided command names and uses 'command -v' -# to verify their existence. If any command is not found, it collects the names -# and reports them all in a single fatal error. -# -# Usage: -# assert_command_exists git curl jq -# -# Arguments: -# $@: One or more command names to check. -# -assert_command_exists() { - local missing_commands=() - local cmd - - if (($# == 0)); then - log_warn "assert_command_exists: No commands provided to check." - return 0 - fi - - for cmd; do - if ! command -v "$cmd" >/dev/null 2>&1; then - missing_commands+=("$cmd") - fi - done - - if ((${#missing_commands[@]} > 0)); then - fatal_error "These required commands were not found in your PATH: ${missing_commands[*]}" - fi - - return 0 -} - -# -# assert_file_exists - Checks that one or more paths exist and are regular files. -# -# This function iterates through all provided paths. If any path does not -# exist or is not a regular file (e.g., it's a directory or a symlink to -# a non-file), it collects the names and reports them all in a single fatal error. -# -# Usage: -# assert_file_exists "/etc/hosts" "./my_script.sh" -# -# Arguments: -# $@: One or more file paths to check. -# -assert_file_exists() { - local missing_files=() - local file - - if (($# == 0)); then - log_warn "assert_file_exists: No files provided to check." - return 0 - fi - - for file; do - if [[ ! -f "$file" ]]; then - missing_files+=("$file") - fi - done - - if ((${#missing_files[@]} > 0)); then - fatal_error "These required files do not exist or are not regular files: ${missing_files[*]}" - fi - - return 0 -} - -# -# assert_dir_exists - Checks that one or more paths exist and are directories. -# -# This function iterates through all provided paths. If any path does not -# exist or is not a directory, it collects the names and reports them all -# in a single fatal error. -# -# Usage: -# assert_dir_exists "/tmp" "/var/log" -# -# Arguments: -# $@: One or more directory paths to check. -# -assert_dir_exists() { - local missing_dirs=() - local dir - - if (($# == 0)); then - log_warn "assert_dir_exists: No directories provided to check." - return 0 - fi - - for dir; do - if [[ ! -d "$dir" ]]; then - missing_dirs+=("$dir") - fi - done - - if ((${#missing_dirs[@]} > 0)); then - fatal_error "These required directories do not exist: ${missing_dirs[*]}" - fi - - return 0 -} - -################################################# MISC FUNCTIONS ####################################################### - -# -# safe_cd - A safe version of the 'cd' command that exits on failure. -# -safe_cd() { - local dir="${1-}" - [[ "$dir" ]] || fatal_error "No arguments or an empty string passed to safe_cd" - cd -- "$dir" || fatal_error "Can't cd to '$dir'" -} - -# -# safe_unalias - Safely unaliases a command, without erroring if it doesn't exist. -# -safe_unalias() { - # Ref: https://stackoverflow.com/a/61471333/6862601 - local alias_name - for alias_name; do - [[ ${BASH_ALIASES[$alias_name]-} ]] && unalias "$alias_name" - done - return 0 -} - -# -# get_my_source_dir - Returns the absolute path to the directory of the calling script through the passed variable name. -# -# Usage: -# get_my_source_dir var_name -# -get_my_source_dir() { - local result_name="${1-}" - [[ -n "$result_name" ]] || fatal_error "get_my_source_dir: No result variable name provided." - local source_dir - # Reference: https://stackoverflow.com/a/246128/6862601 - source_dir="$(cd "$(dirname "${BASH_SOURCE[1]}")" >/dev/null 2>&1 && pwd -P)" || - fatal_error "get_my_source_dir: Unable to resolve source directory." - printf -v "$result_name" '%s' "$source_dir" -} - -# -# ask_yes_no - Get user's confirmation -# -# Prompts the user with a given message for a yes/no answer and returns 0 or 1 -# based on user's choice of yes or no. It reads a single character without -# requiring the user to press Enter. -# -# Arguments: -# $1: The message string to display as the prompt. -# -# Usage: -# -# if ask_yes_no "Do you want to continue?"; then -# echo "User chose to continue." -# else -# echo "User chose not to continue." -# fi -# -ask_yes_no() { - if (("$#" != 1)); then - log_error "ask_yes_no: invalid arguments" - log_info "Usage: ask_yes_no " - return 1 - fi - - local message=$1 user_input - while true; do - # Prompt the user for input. - # -n 1: Reads only one character. - # -r: Prevents backslash from acting as an escape character. - # -p: Displays the prompt string. - # The text "[y/N]" suggests that 'N' is the default choice. - read -r -n 1 -p "$message [y/N]: " user_input - - # Add a newline since the user won't press Enter. - echo - - case "$user_input" in - [yY]) return 0;; - [nN]) return 1;; - *) echo "Invalid input. Please enter 'y' or 'n'.";; - esac - done -} - -# -# wait_for_enter - Pauses the script and waits for the user to press the Enter key. -# -# Arguments: -# $1: (Optional) The prompt to display. Defaults to "Press Enter to continue". -# -wait_for_enter() { - local prompt=${1:-"Press Enter to continue"} - read -r -s -p "$prompt" "$script_path" - chmod +x "$script_path" -} - -normalize_tty_output() { - local text="$1" - text="${text//$'\r'/}" - text="${text//$'\b'/}" - printf '%s' "$text" -} - -run_tty_script() { - local script_path="$1" - local command - shift - - command -v script >/dev/null 2>&1 || skip "The 'script' command is required for tty tests." - - if script --version >/dev/null 2>&1; then - printf -v command '%q ' "$script_path" "$@" - bats_run script -q -e -c "${command% }" /dev/null - else - bats_run script -q /dev/null "$script_path" "$@" - fi -} - -setup() { - setup_test_tmpdir - PATH="$BANYAN_TEST_ORIG_PATH" - unset DRY_RUN dry_run LOG_DEBUG LOG_UTC BANYAN_BASH_BOOTSTRAP_SOURCE - source "$STDLIB_PATH" -} - -teardown() { - PATH="$BANYAN_TEST_ORIG_PATH" -} - -@test "sourcing stdlib preserves original args and strips wrapper flags" { - local script="$TEST_TMPDIR/check-init.sh" - - create_script "$script" < "$relative_dir/relative.sh" <<'EOF' -REL_IMPORTED="relative" -EOF - cat > "$absolute_lib" <<'EOF' -ABS_IMPORTED="absolute" -EOF - - create_script "$script" <"$stderr_file"; then - rc=0 - else - rc=$? - fi - - [ "$rc" -eq 1 ] - [[ "$(cat "$stderr_file")" == *"add_to_path: invalid option"* ]] -} - -@test "dedupe_path removes duplicates and empty entries" { - PATH="/one:/two:/one::/three:/two" - - dedupe_path - - [ "$PATH" = "/one:/two:/three" ] -} - -@test "print_path emits one path entry per line" { - PATH="/one:/two:/three" - - bats_run print_path - - [ "$status" -eq 0 ] - [ "$output" = $'/one\n/two\n/three' ] -} - -@test "__join_message__ joins fragments with single spaces" { - local joined - - joined="$(__join_message__ alpha beta "gamma delta")" - - [ "$joined" = "alpha beta gamma delta" ] -} - -@test "log initialization sets the default logger map" { - [ "${_log_levels[ERROR]}" -eq 1 ] - [ "${_log_levels[VERBOSE]}" -eq 5 ] - [ "${_loggers_level_map[default]}" -eq 3 ] - [ -z "${COLOR_RED:-}" ] -} - -@test "set_log_level updates named loggers and falls back for unknown levels" { - local stderr_file="$TEST_TMPDIR/set-log-level.err" - - set_log_level -l custom DEBUG - [ "${_loggers_level_map[custom]}" -eq 4 ] - - set_log_level -l custom NOPE 2>"$stderr_file" - [ "${_loggers_level_map[custom]}" -eq 3 ] - [[ "$(cat "$stderr_file")" == *"Unknown log level 'NOPE'"* ]] -} - -@test "_print_log requires a log level" { - ! _print_log -} - -@test "log wrappers respect the configured log level" { - local stderr_file="$TEST_TMPDIR/log-wrappers.err" - - : > "$stderr_file" - log_debug hidden 2>"$stderr_file" - [ ! -s "$stderr_file" ] - - set_log_level VERBOSE - { - log_fatal "fatal message" - log_error "error message" - log_warn "warn message" - log_info "info message" - log_debug "debug message" - log_verbose "verbose message" - } 2>"$stderr_file" - - [[ "$(cat "$stderr_file")" == *"FATAL"* ]] - [[ "$(cat "$stderr_file")" == *"ERROR"* ]] - [[ "$(cat "$stderr_file")" == *"WARN"* ]] - [[ "$(cat "$stderr_file")" == *"INFO"* ]] - [[ "$(cat "$stderr_file")" == *"DEBUG"* ]] - [[ "$(cat "$stderr_file")" == *"VERBOSE"* ]] -} - -@test "file logging helpers print contents and warn on unknown loggers" { - local target="$TEST_TMPDIR/log-target.txt" - local stderr_file="$TEST_TMPDIR/log-file.err" - - printf 'hello file\n' > "$target" - - log_debug_file "$target" 2>"$stderr_file" - [ ! -s "$stderr_file" ] - - set_log_level DEBUG - log_debug_file "$target" 2>"$stderr_file" - [[ "$(cat "$stderr_file")" == *"Contents of file '$target':"* ]] - [[ "$(cat "$stderr_file")" == *"hello file"* ]] - - _print_log_file INFO -l missing "$target" 2>"$stderr_file" - [[ "$(cat "$stderr_file")" == *"Unknown logger 'missing'"* ]] -} - -@test "enter and leave logging helpers include the caller name" { - local stderr_file="$TEST_TMPDIR/enter-leave.err" - - trace_me() { - log_info_enter - log_debug_enter - log_verbose_enter - log_info_leave - log_debug_leave - log_verbose_leave - } - - set_log_level VERBOSE - trace_me 2>"$stderr_file" - - [[ "$(cat "$stderr_file")" == *"Entering function trace_me"* ]] - [[ "$(cat "$stderr_file")" == *"Leaving function trace_me"* ]] -} - -@test "print helpers emit expected text" { - local stderr_file="$TEST_TMPDIR/print.err" - local stdout_file="$TEST_TMPDIR/print.out" - - { - print_error "bad news" - print_warn "careful" - print_info "heads up" - print_success "all good" - } 2>"$stderr_file" - - { - print_bold "strong text" - print_message "line one" "line two" - } >"$stdout_file" - - [[ "$(cat "$stderr_file")" == *"ERROR: bad news"* ]] - [[ "$(cat "$stderr_file")" == *"WARN: careful"* ]] - [[ "$(cat "$stderr_file")" == *"heads up"* ]] - [[ "$(cat "$stderr_file")" == *"SUCCESS: all good"* ]] - [ "$(cat "$stdout_file")" = $'strong text\nline one\nline two' ] -} - -@test "print_tty is silent without a tty" { - local stdout_file="$TEST_TMPDIR/tty.out" - - print_tty "hidden output" >"$stdout_file" - - [ ! -s "$stdout_file" ] -} - -@test "print_tty emits output when a tty is present" { - local script="$TEST_TMPDIR/print-tty.sh" - local normalized - - create_script "$script" <"$stderr_file"; then - rc=0 - else - rc=$? - fi - - [ "$rc" -eq 1 ] - [[ "$(cat "$stderr_file")" == *"run: No command provided."* ]] -} - -@test "run honors dry-run mode without executing the command" { - local target="$TEST_TMPDIR/dry-run.txt" - DRY_RUN=true - - run touch "$target" - - [ "$?" -eq 0 ] - [ ! -e "$target" ] -} - -@test "run --no-exit returns the underlying failure status" { - local stderr_file="$TEST_TMPDIR/run-no-exit.err" - local rc - - if run --no-exit bash -c 'exit 7' 2>"$stderr_file"; then - rc=0 - else - rc=$? - fi - - [ "$rc" -eq 7 ] - [[ "$(cat "$stderr_file")" == *"continuing"* ]] -} - -@test "run exits the script on failure by default" { - local script="$TEST_TMPDIR/run-fail.sh" - - create_script "$script" <"$stderr_file" - - [ "$?" -eq 0 ] - [[ "$(cat "$stderr_file")" == *"safe_touch: No files provided to touch."* ]] -} - -@test "safe_touch exits when a file cannot be touched" { - local script="$TEST_TMPDIR/safe-touch-fail.sh" - - create_script "$script" < "$target" - safe_truncate "$target" - - [ ! -s "$target" ] -} - -@test "safe_truncate warns when no files are provided" { - local stderr_file="$TEST_TMPDIR/safe-truncate.err" - - safe_truncate 2>"$stderr_file" - - [ "$?" -eq 0 ] - [[ "$(cat "$stderr_file")" == *"safe_truncate: No files provided to truncate."* ]] -} - -@test "safe_truncate exits when a file cannot be truncated" { - local script="$TEST_TMPDIR/safe-truncate-fail.sh" - - create_script "$script" <"$stderr_file" - [ "$?" -eq 0 ] - [[ "$(cat "$stderr_file")" == *"assert_command_exists: No commands provided to check."* ]] -} - -@test "assert_command_exists exits for missing commands" { - local script="$TEST_TMPDIR/assert-command-fail.sh" - - create_script "$script" <"$stderr_file" - [ "$?" -eq 0 ] - [[ "$(cat "$stderr_file")" == *"assert_file_exists: No files provided to check."* ]] -} - -@test "assert_file_exists exits for missing files" { - local script="$TEST_TMPDIR/assert-file-fail.sh" - - create_script "$script" <"$stderr_file" - [ "$?" -eq 0 ] - [[ "$(cat "$stderr_file")" == *"assert_dir_exists: No directories provided to check."* ]] -} - -@test "assert_dir_exists exits for missing directories" { - local script="$TEST_TMPDIR/assert-dir-fail.sh" - - create_script "$script" </dev/null 2>&1 -} - -@test "get_my_source_dir returns the caller script directory" { - local script="$TEST_TMPDIR/get-source-dir.sh" - local expected_dir - - create_script "$script" <"$stderr_file"; then - rc=0 - else - rc=$? - fi - - [ "$rc" -eq 1 ] - [[ "$(cat "$stderr_file")" == *"ask_yes_no: invalid arguments"* ]] -} - -@test "wait_for_enter returns after receiving a newline on a tty" { - skip "wait_for_enter reads from /dev/tty and needs a more reliable pseudo-tty harness on macOS." -} diff --git a/cli/bash/tests/test_helper.bash b/cli/bash/tests/test_helper.bash deleted file mode 100644 index c5b68ff..0000000 --- a/cli/bash/tests/test_helper.bash +++ /dev/null @@ -1,50 +0,0 @@ -# Common helpers for Bash library BATS suites. - -# Preserve BATS' built-in `run` helper before lib_std.sh defines its own. -if declare -f run >/dev/null 2>&1; then - eval "$(declare -f run | sed '1 s/^run /bats_run /')" -fi - -readonly BANYAN_BASH_TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -readonly BANYAN_BASH_DIR="$(cd "$BANYAN_BASH_TESTS_DIR/.." && pwd -P)" -readonly BANYAN_REPO_ROOT="$(cd "$BANYAN_BASH_DIR/../.." && pwd -P)" -readonly BANYAN_TEST_ORIG_PATH="$PATH" - -setup_test_tmpdir() { - TEST_TMPDIR="${BATS_TEST_TMPDIR}/workspace" - mkdir -p "$TEST_TMPDIR" -} - -init_git_repo() { - local repo_dir="$1" - - mkdir -p "$repo_dir" - git init "$repo_dir" >/dev/null 2>&1 - git -C "$repo_dir" checkout -B master >/dev/null 2>&1 - git -C "$repo_dir" config user.name "Bats Test" - git -C "$repo_dir" config user.email "bats@example.com" -} - -commit_all() { - local repo_dir="$1" - local message="${2:-test commit}" - - git -C "$repo_dir" add -A - git -C "$repo_dir" commit -m "$message" >/dev/null 2>&1 -} - -create_tracked_repo_with_upstream() { - local repo_dir="$1" - local remote_dir="$2" - local rel_path="$3" - local content="${4:-sample content}" - - init_git_repo "$repo_dir" - mkdir -p "$(dirname "$repo_dir/$rel_path")" - printf '%s\n' "$content" > "$repo_dir/$rel_path" - commit_all "$repo_dir" "Initial commit" - - git init --bare "$remote_dir" >/dev/null 2>&1 - git -C "$repo_dir" remote add origin "$remote_dir" - git -C "$repo_dir" push -u origin master >/dev/null 2>&1 -} diff --git a/cli/env/README.md b/cli/env/README.md deleted file mode 100644 index 184f809..0000000 --- a/cli/env/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# `cli/env` - -This directory holds the shared CLI environment bootstrap. - -## Purpose - -`banyanenv.sh` defines the common shell environment used by: - -- `cli/bash/bin/bash-wrapper` -- interactive shells that source it from `~/.bashrc` or `~/.zshrc` -- future Bash and Python CLIs that want a single, shared environment contract - -## Usage - -Source it from a shell startup file or from another script: - -```bash -source /path/to/banyanlabs/cli/env/banyanenv.sh -``` - -It must be sourced rather than executed. - -## What It Exports - -- `BANYAN_REPO_ROOT` -- `BANYAN_CLI_ROOT` -- `BANYAN_CLI_ENV_DIR` -- `BANYAN_CLI_ENV_SCRIPT` -- `BANYAN_BASH_ROOT` -- `BANYAN_BASH_BIN_DIR` -- `BANYAN_BASH_LIB_DIR` -- `BANYAN_BASH_COMMANDS_DIR` -- `BANYAN_PYTHON_ROOT` - -It also prepends `cli/bash/bin` to `PATH` when that directory exists, without duplicating the entry on repeated sourcing. - -## Compatibility - -`banyanenv.sh` is designed to work in both Bash and zsh. - -## Tests - -Run the environment bootstrap test suite with: - -```bash -bats cli/env/tests/banyanenv.bats -``` diff --git a/cli/env/banyanenv.sh b/cli/env/banyanenv.sh deleted file mode 100644 index 54eb5d0..0000000 --- a/cli/env/banyanenv.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash - -# -# banyanenv.sh -# Sets up the Banyan Labs CLI shell environment. -# Source this file from bash-wrapper or from ~/.bashrc / ~/.zshrc: -# source /path/to/banyanlabs/cli/env/banyanenv.sh -# Compatible with both bash and zsh. -# - -banyanenv_error() { - printf 'ERROR: %s\n' "$*" >&2 -} - -banyanenv_is_sourced() { - if [[ -n "${BASH_VERSION:-}" ]]; then - [[ "${BASH_SOURCE[0]}" != "$0" ]] - return - fi - - if [[ -n "${ZSH_VERSION:-}" ]]; then - [[ "$(eval 'printf "%s\n" "${(%):-%x}"')" != "$0" ]] - return - fi - - return 1 -} - -banyanenv_get_source_path() { - if [[ -n "${BASH_VERSION:-}" ]]; then - printf '%s\n' "${BASH_SOURCE[0]}" - return 0 - fi - - if [[ -n "${ZSH_VERSION:-}" ]]; then - eval 'printf "%s\n" "${(%):-%x}"' - return 0 - fi - - return 1 -} - -banyanenv_prepend_path() { - local dir="$1" - - [[ -n "$dir" && -d "$dir" ]] || return 0 - - case ":${PATH:-}:" in - *":$dir:"*) ;; - *) - if [[ -n "${PATH:-}" ]]; then - PATH="$dir:$PATH" - else - PATH="$dir" - fi - export PATH - ;; - esac -} - -banyanenv_main() { - local source_path env_dir cli_root repo_root bash_root python_root - - source_path="$(banyanenv_get_source_path)" || { - banyanenv_error "Unable to determine the path to banyanenv.sh." - return 1 - } - [[ -n "$source_path" ]] || { - banyanenv_error "Unable to determine the path to banyanenv.sh." - return 1 - } - - env_dir="$(cd -- "$(dirname -- "$source_path")" && pwd -P)" || { - banyanenv_error "Unable to resolve cli/env root from '$source_path'." - return 1 - } - cli_root="$(cd -- "$env_dir/.." && pwd -P)" || { - banyanenv_error "Unable to resolve cli root from '$env_dir'." - return 1 - } - repo_root="$(cd -- "$cli_root/.." && pwd -P)" || { - banyanenv_error "Unable to resolve repository root from '$cli_root'." - return 1 - } - - bash_root="$cli_root/bash" - python_root="$cli_root/python" - - export BANYAN_REPO_ROOT="$repo_root" - export BANYAN_CLI_ROOT="$cli_root" - export BANYAN_CLI_ENV_DIR="$env_dir" - export BANYAN_CLI_ENV_SCRIPT="$env_dir/banyanenv.sh" - export BANYAN_BASH_ROOT="$bash_root" - export BANYAN_BASH_BIN_DIR="$bash_root/bin" - export BANYAN_BASH_LIB_DIR="$bash_root/lib" - export BANYAN_BASH_COMMANDS_DIR="$bash_root/commands" - export BANYAN_PYTHON_ROOT="$python_root" - - banyanenv_prepend_path "$BANYAN_BASH_BIN_DIR" - - return 0 -} - -if ! banyanenv_is_sourced; then - banyanenv_error "banyanenv.sh must be sourced, not executed." - banyanenv_error "Use: source /path/to/banyanlabs/cli/env/banyanenv.sh" - exit 1 -fi - -banyanenv_main -_banyanenv_rc=$? -unset -f banyanenv_error banyanenv_is_sourced banyanenv_get_source_path banyanenv_prepend_path banyanenv_main -if [[ $_banyanenv_rc -ne 0 ]]; then - return "$_banyanenv_rc" -fi -unset _banyanenv_rc diff --git a/cli/env/tests/banyanenv.bats b/cli/env/tests/banyanenv.bats deleted file mode 100644 index d264dd5..0000000 --- a/cli/env/tests/banyanenv.bats +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bats - -load ../../bash/tests/test_helper.bash - -readonly BANYAN_ENV_SCRIPT="$BANYAN_REPO_ROOT/cli/env/banyanenv.sh" - -create_env_layout() { - local repo_root="$1" - - mkdir -p "$repo_root/cli/env" "$repo_root/cli/bash/bin" "$repo_root/cli/bash/lib" "$repo_root/cli/bash/commands" "$repo_root/cli/python" - cp "$BANYAN_ENV_SCRIPT" "$repo_root/cli/env/banyanenv.sh" -} - -@test "banyanenv must be sourced rather than executed" { - run bash "$BANYAN_ENV_SCRIPT" - - [ "$status" -eq 1 ] - [[ "$output" == *"banyanenv.sh must be sourced, not executed."* ]] -} - -@test "sourcing banyanenv under bash exports shared CLI roots and updates PATH once" { - local repo_root="$BATS_TEST_TMPDIR/repo" - local script="$BATS_TEST_TMPDIR/check-bash-env.sh" - local expected_repo_root expected_cli_root expected_env_dir expected_bash_root expected_bash_bin expected_python_root - - create_env_layout "$repo_root" - expected_repo_root="$(cd "$repo_root" && pwd -P)" - expected_cli_root="$(cd "$repo_root/cli" && pwd -P)" - expected_env_dir="$(cd "$repo_root/cli/env" && pwd -P)" - expected_bash_root="$(cd "$repo_root/cli/bash" && pwd -P)" - expected_bash_bin="$(cd "$repo_root/cli/bash/bin" && pwd -P)" - expected_python_root="$(cd "$repo_root/cli/python" && pwd -P)" - - cat > "$script" <