From 8a2c8281e0c816973004eea623e2e56f573fe6a2 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sun, 12 Apr 2026 14:29:40 -0700 Subject: [PATCH] Standardize Bash CLI entrypoints around .sh commands and per-command scripts --- cli/bash/bin/README.md | 18 +- cli/bash/bin/bash-wrapper | 24 +- cli/bash/bin/{test_cmd => setup.sh} | 0 cli/bash/bin/test_cmd.sh | 1 + cli/bash/bin/tests/bash-wrapper.bats | 78 +++--- cli/bash/commands/setup/README.md | 80 ++++++ cli/bash/commands/setup/setup.sh | 312 +++++++++++++++++++++++ cli/bash/commands/setup/tests/setup.bats | 285 +++++++++++++++++++++ cli/bash/lib/std/tests/lib_std.bats | 2 +- 9 files changed, 747 insertions(+), 53 deletions(-) rename cli/bash/bin/{test_cmd => setup.sh} (100%) create mode 120000 cli/bash/bin/test_cmd.sh create mode 100644 cli/bash/commands/setup/README.md create mode 100644 cli/bash/commands/setup/setup.sh create mode 100644 cli/bash/commands/setup/tests/setup.bats diff --git a/cli/bash/bin/README.md b/cli/bash/bin/README.md index c45fb13..9a91ade 100644 --- a/cli/bash/bin/README.md +++ b/cli/bash/bin/README.md @@ -6,7 +6,7 @@ This directory holds the user-facing Bash entrypoints. - `bash-wrapper` The shared dispatcher used to launch Bash commands. -- `` symlinks +- `.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`. @@ -16,16 +16,16 @@ This directory holds the user-facing Bash entrypoints. The wrapper supports two invocation styles: ```bash -bash-wrapper [args...] - [args...] +bash-wrapper .sh [args...] +.sh [args...] ``` Behavior: - When invoked as `bash-wrapper`, the first argument is treated as the command name. -- When invoked through a symlink, the symlink name is treated as the command name. -- Commands are resolved under `../commands//main.sh`. -- As a compatibility fallback, `../commands//.sh` is also supported. +- 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 @@ -61,14 +61,14 @@ That keeps interactive shells and wrapper-launched commands on the same environm Direct dispatch: ```bash -cli/bash/bin/bash-wrapper my-command --flag value +cli/bash/bin/bash-wrapper my-command.sh --flag value ``` Symlink dispatch: ```bash -ln -s bash-wrapper cli/bash/bin/my-command -cli/bash/bin/my-command --flag value +ln -s bash-wrapper cli/bash/bin/my-command.sh +cli/bash/bin/my-command.sh --flag value ``` ## Tests diff --git a/cli/bash/bin/bash-wrapper b/cli/bash/bin/bash-wrapper index e3f2a10..b0ff106 100755 --- a/cli/bash/bin/bash-wrapper +++ b/cli/bash/bin/bash-wrapper @@ -29,6 +29,16 @@ resolve_script_path() { 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 @@ -38,8 +48,8 @@ list_commands() { while IFS= read -r command_dir; do [[ -d "$command_dir" ]] || continue command_name="$(basename "$command_dir")" - if [[ -f "$command_dir/main.sh" || -f "$command_dir/${command_name}.sh" ]]; then - printf ' %s\n' "$command_name" + 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) @@ -58,9 +68,9 @@ Usage: 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//main.sh. - - As a compatibility fallback, cli/bash/commands//.sh is also accepted. + - Commands are resolved under cli/bash/commands//.sh. Available commands: EOF @@ -101,6 +111,8 @@ main() { command_name="$invoked_as" fi + command_name="$(normalize_command_name "$command_name")" + case "$command_name" in ""|.|..|*/* ) die "Invalid command name '$command_name'." @@ -119,9 +131,7 @@ main() { env_script="${BANYAN_CLI_ENV_SCRIPT:-$env_script}" command_dir="$commands_dir/$command_name" - if [[ -f "$command_dir/main.sh" ]]; then - command_script="$command_dir/main.sh" - elif [[ -f "$command_dir/${command_name}.sh" ]]; then + 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'." diff --git a/cli/bash/bin/test_cmd b/cli/bash/bin/setup.sh similarity index 100% rename from cli/bash/bin/test_cmd rename to cli/bash/bin/setup.sh diff --git a/cli/bash/bin/test_cmd.sh b/cli/bash/bin/test_cmd.sh new file mode 120000 index 0000000..e118386 --- /dev/null +++ b/cli/bash/bin/test_cmd.sh @@ -0,0 +1 @@ +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 index 9b03c03..9fee5b5 100644 --- a/cli/bash/bin/tests/bash-wrapper.bats +++ b/cli/bash/bin/tests/bash-wrapper.bats @@ -18,7 +18,7 @@ create_bare_wrapper_layout() { create_wrapper_layout() { local layout_root="$1" local command_name="$2" - local command_script_name="${3:-main.sh}" + local command_script_name="${3:-$command_name.sh}" create_bare_wrapper_layout "$layout_root" mkdir -p "$layout_root/commands/$command_name" @@ -42,7 +42,7 @@ EOF chmod +x "$layout_root/commands/$command_name/$command_script_name" } -@test "bash-wrapper dispatches directly to commands//main.sh" { +@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 @@ -54,9 +54,9 @@ EOF 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)/main.sh" + expected_script_path="$(cd "$layout/commands/demo" && pwd -P)/demo.sh" - run "$layout/bin/bash-wrapper" demo --debug-wrapper alpha beta + run "$layout/bin/bash-wrapper" demo.sh --debug-wrapper alpha beta [ "$status" -eq 0 ] [[ "$output" == *"script_dir=$expected_command_dir"* ]] @@ -71,18 +71,18 @@ EOF [[ "$output" == *"argv=alpha beta"* ]] } -@test "symlink name selects the command" { +@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" + 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)/main.sh" + expected_script_path="$(cd "$layout/commands/greet" && pwd -P)/greet.sh" - run "$layout/bin/greet" hello world + run "$layout/bin/greet.sh" hello world [ "$status" -eq 0 ] [[ "$output" == *"script_dir=$expected_command_dir"* ]] @@ -91,25 +91,6 @@ EOF [[ "$output" == *"argv=hello world"* ]] } -@test "wrapper supports the fallback commands//.sh layout" { - 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" legacy "legacy.sh" - expected_command_dir="$(cd "$layout/commands/legacy" && pwd -P)" - expected_script_path="$(cd "$layout/commands/legacy" && pwd -P)/legacy.sh" - - run "$layout/bin/bash-wrapper" legacy arg1 - - [ "$status" -eq 0 ] - [[ "$output" == *"script_dir=$expected_command_dir"* ]] - [[ "$output" == *"command=legacy"* ]] - [[ "$output" == *"script=$expected_script_path"* ]] - [[ "$output" == *"argv=arg1"* ]] -} - @test "wrapper prints usage when no command is provided" { local repo_root="$BATS_TEST_TMPDIR/repo" local layout="$repo_root/cli/bash" @@ -149,13 +130,20 @@ EOF 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"* ]] - [[ "$output" == *" legacy"* ]] + [[ "$output" == *" alpha.sh"* ]] + [[ "$output" == *" legacy.sh"* ]] [[ "$output" != *"empty-dir"* ]] + [[ "$output" != *"main-only"* ]] [[ "$output" != *"readme-only"* ]] } @@ -189,12 +177,30 @@ EOF create_bare_wrapper_layout "$layout" - run "$layout/bin/bash-wrapper" missing + 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" @@ -227,14 +233,14 @@ EOF create_bare_wrapper_layout "$layout" mkdir -p "$layout/commands/stdlib-demo" - cat > "$layout/commands/stdlib-demo/main.sh" <<'EOF' + 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/main.sh" + chmod +x "$layout/commands/stdlib-demo/stdlib-demo.sh" run "$layout/bin/bash-wrapper" stdlib-demo @@ -250,14 +256,14 @@ EOF create_bare_wrapper_layout "$layout" mkdir -p "$layout/commands/flags" - cat > "$layout/commands/flags/main.sh" <<'EOF' + 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/main.sh" + chmod +x "$layout/commands/flags/flags.sh" run "$layout/bin/bash-wrapper" flags --verbose-wrapper --utc-wrapper --color one two @@ -273,10 +279,10 @@ EOF local layout="$repo_root/cli/bash" create_bare_wrapper_layout "$layout" - ln -s bash-wrapper "$layout/bin/orphan" + ln -s bash-wrapper "$layout/bin/orphan.sh" mkdir -p "$layout/commands/orphan" - run "$layout/bin/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 new file mode 100644 index 0000000..722d09a --- /dev/null +++ b/cli/bash/commands/setup/README.md @@ -0,0 +1,80 @@ +# `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, and creates `$HOME/.banyan_venv`. +- `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 via Homebrew if it is not already installed +4. create `$HOME/.banyan_venv` if it does not already exist + +## 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 +``` + +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_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 new file mode 100644 index 0000000..5b3f911 --- /dev/null +++ b/cli/bash/commands/setup/setup.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash + +setup_usage() { + cat <<'EOF' +Usage: + setup [options] + +Commands: + install + Install Homebrew, Xcode Command Line Tools, Python, and ~/.banyan_venv. + 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 the local Banyan Labs CLI environment on macOS. + +What it does: + 1. Install Homebrew if needed. + 2. Install Xcode Command Line Tools if needed. + 3. Install Python via Homebrew if needed. + 4. Create ~/.banyan_venv if it does not already exist. + +Notes: + - This command is intentionally idempotent. + - It does not support uninstall yet. +EOF +} + +setup_is_dry_run() { + [[ "${DRY_RUN-}" == true || "${dry_run-}" == true ]] +} + +setup_venv_dir() { + printf '%s\n' "${BANYAN_SETUP_VENV_DIR:-$HOME/.banyan_venv}" +} + +setup_python_formula() { + printf '%s\n' "${BANYAN_SETUP_PYTHON_FORMULA:-python}" +} + +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_find_python_bin() { + local formula prefix + + 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" && -x "$prefix/bin/python3" ]]; then + printf '%s\n' "$prefix/bin/python3" + return 0 + 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 [[ -f "$venv_dir/bin/activate" || -f "$venv_dir/pyvenv.cfg" ]]; 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_install() { + setup_require_macos + setup_install_homebrew + setup_install_xcode_tools + setup_install_python + setup_create_virtualenv + + print_success "Banyan Labs CLI setup is complete." +} + +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|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 + ;; + 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 new file mode 100644 index 0000000..e3fde70 --- /dev/null +++ b/cli/bash/commands/setup/tests/setup.bats @@ -0,0 +1,285 @@ +#!/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}" + +case "${1:-}" in + list) + if [[ "${2:-}" == "$python_formula" && -f "$state_dir/python-installed" ]]; then + exit 0 + fi + 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 + 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}" + +case "${1:-}" in + list) + if [[ "${2:-}" == "$python_formula" && -f "$state_dir/python-installed" ]]; then + exit 0 + fi + 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 + 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" == *"Prepare 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/.banyan_venv" + + create_brew_stub + create_xcode_stubs + touch "$TEST_STATE_DIR/xcode-installed" + mkdir -p "$TEST_TMPDIR/CommandLineTools" + touch "$TEST_STATE_DIR/python-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' is already installed via Homebrew."* ]] + [[ "$output" == *"Virtual environment already exists at '$venv_dir'."* ]] + [ ! -f "$TEST_STATE_DIR/python-install-ran" ] +} + +@test "setup installs missing dependencies and creates the Banyan virtual environment" { + local installer + local venv_dir="$TEST_HOME/.banyan_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' 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 "$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' via Homebrew."* ]] + [[ "$output" == *"[DRY-RUN] Would create Python virtual environment at '$TEST_HOME/.banyan_venv'."* ]] + [ ! -e "$TEST_HOME/.banyan_venv" ] +} + +@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/lib/std/tests/lib_std.bats b/cli/bash/lib/std/tests/lib_std.bats index 9575e84..0a8fd8f 100644 --- a/cli/bash/lib/std/tests/lib_std.bats +++ b/cli/bash/lib/std/tests/lib_std.bats @@ -66,7 +66,7 @@ EOF create_script "$script" <